From 4c0d05fdc09418c16c87b8c50a81da9cc43ac871 Mon Sep 17 00:00:00 2001 From: Pavlo Maksymchuk Date: Sat, 7 Nov 2020 16:16:42 +0000 Subject: [PATCH 01/47] Feature/amplitude push --- src/integrations/amplitude/amplitude.py | 15 ++++++++------- .../amplitude/tests/test_amplitude.py | 10 ++++------ 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/src/integrations/amplitude/amplitude.py b/src/integrations/amplitude/amplitude.py index 90483a7abb60..60754150847c 100644 --- a/src/integrations/amplitude/amplitude.py +++ b/src/integrations/amplitude/amplitude.py @@ -1,4 +1,4 @@ -import json +import urllib.parse import requests @@ -13,10 +13,12 @@ class AmplitudeWrapper: def __init__(self, api_key: str): self.api_key = api_key - self.url = f"{AMPLITUDE_API_URL}/identify" + self.url = f"{AMPLITUDE_API_URL}/identify?api_key={self.api_key}" def _identify_user(self, user_data: dict) -> None: - response = requests.post(self.url, data=user_data) + self.url = self.url + "&" + urllib.parse.urlencode(user_data) + + response = requests.post(self.url) logger.debug("Sent event to Amplitude. Response code was: %s" % response.status_code) @postpone @@ -24,15 +26,14 @@ def identify_user_async(self, user_data: dict) -> None: self._identify_user(user_data) def generate_user_data(self, user_id, feature_states): - user_data = { - "api_key": self.api_key, "identification": { "user_id": user_id, - "user_properties": json.dumps({ + "user_properties": { feature_state.feature.name: feature_state.get_feature_state_value() + if feature_state.get_feature_state_value() is not None else "None" for feature_state in feature_states - }) + } } } diff --git a/src/integrations/amplitude/tests/test_amplitude.py b/src/integrations/amplitude/tests/test_amplitude.py index 97ff536c0f3f..460819ea4703 100644 --- a/src/integrations/amplitude/tests/test_amplitude.py +++ b/src/integrations/amplitude/tests/test_amplitude.py @@ -1,5 +1,3 @@ -import json - import pytest from environments.models import Environment @@ -17,7 +15,7 @@ def test_amplitude_initialized_correctly(): amplitude_wrapper = AmplitudeWrapper(api_key=api_key) # Then - expected_url = f"{AMPLITUDE_API_URL}/identify" + expected_url = f"{AMPLITUDE_API_URL}/identify?api_key={api_key}" assert amplitude_wrapper.url == expected_url @@ -40,13 +38,13 @@ def test_amplitude_when_generate_user_data_with_correct_values_then_success(): # Then expected_user_data = { - "api_key": api_key, "identification": { "user_id": user_id, - "user_properties": json.dumps({ + "user_properties": { feature_state.feature.name: feature_state.get_feature_state_value() + if feature_state.get_feature_state_value() is not None else "None" for feature_state in feature_states - }) + } } } assert expected_user_data == user_data From 000a6c1ac1fcfa430951149ee75064ed36f995f6 Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Mon, 9 Nov 2020 21:20:16 +0000 Subject: [PATCH 02/47] Tidy up settings --- src/app/settings/common.py | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/src/app/settings/common.py b/src/app/settings/common.py index bab6f339839f..2b158440a700 100644 --- a/src/app/settings/common.py +++ b/src/app/settings/common.py @@ -53,15 +53,8 @@ INFLUXDB_URL = env.str('INFLUXDB_URL', default='') INFLUXDB_ORG = env.str('INFLUXDB_ORG', default='') -if 'DJANGO_ALLOWED_HOSTS' in os.environ: - ALLOWED_HOSTS = os.environ['DJANGO_ALLOWED_HOSTS'].split(',') -else: - ALLOWED_HOSTS = [] - -if 'DJANGO_CSRF_TRUSTED_ORIGINS' in os.environ: - CSRF_TRUSTED_ORIGINS = os.environ['DJANGO_CSRF_TRUSTED_ORIGINS'].split(',') -else: - CSRF_TRUSTED_ORIGINS = [] +ALLOWED_HOSTS = env.list('DJANGO_ALLOWED_HOSTS', default=[]) +CSRF_TRUSTED_ORIGINS = env.list('DJANGO_CSRF_TRUSTED_ORIGINS', default=[]) INTERNAL_IPS = ['127.0.0.1',] From cec07b86f2e7758e21a31b2a33f6486696b4811c Mon Sep 17 00:00:00 2001 From: Maciej Krol Date: Mon, 16 Nov 2020 19:40:26 +0100 Subject: [PATCH 03/47] Ignore failing Chargebee Webhooks - return 200 --- src/organisations/tests/test_views.py | 2 +- src/organisations/views.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/organisations/tests/test_views.py b/src/organisations/tests/test_views.py index 8b0fe5970d27..b166517a9825 100644 --- a/src/organisations/tests/test_views.py +++ b/src/organisations/tests/test_views.py @@ -436,7 +436,7 @@ def test_when_chargebee_webhook_received_with_unknown_subscription_id_then_404(s res = self.client.post(self.url, data=json.dumps(data), content_type='application/json') # Then - assert res.status_code == status.HTTP_400_BAD_REQUEST + assert res.status_code == status.HTTP_200_OK @pytest.mark.django_db diff --git a/src/organisations/views.py b/src/organisations/views.py index 7a30719b0931..8ba4ced6e761 100644 --- a/src/organisations/views.py +++ b/src/organisations/views.py @@ -158,7 +158,7 @@ def chargebee_webhook(request): except (Subscription.DoesNotExist, Subscription.MultipleObjectsReturned): error_message = 'Couldn\'t get unique subscription for ChargeBee id %s' % subscription_data.get('id') logger.error(error_message) - return Response(data=error_message, status=status.HTTP_400_BAD_REQUEST) + return Response(status=status.HTTP_200_OK) subscription_status = subscription_data.get('status') if subscription_status == 'active': From 091b6e1224fde788e326c44d039cc7ff31bfc605 Mon Sep 17 00:00:00 2001 From: Ben Rometsch Date: Tue, 17 Nov 2020 09:52:12 +0000 Subject: [PATCH 04/47] Added feature_analytics boolean to Org model Very basic write implementation --- src/analytics/influxdb_wrapper.py | 23 +++---- src/analytics/tests/test_influxdb_wrapper.py | 3 +- src/analytics/tests/test_unit_track.py | 2 +- src/analytics/track.py | 23 ++++++- src/analytics/views.py | 25 ++++++++ src/api/urls.py.bak | 62 ------------------- src/api/urls/v1.py | 2 + .../0024_organisation_feature_analytics.py | 18 ++++++ src/organisations/models.py | 1 + 9 files changed, 83 insertions(+), 76 deletions(-) create mode 100644 src/analytics/views.py delete mode 100644 src/api/urls.py.bak create mode 100644 src/organisations/migrations/0024_organisation_feature_analytics.py diff --git a/src/analytics/influxdb_wrapper.py b/src/analytics/influxdb_wrapper.py index 6fe72b0a936a..b5cd688a333e 100644 --- a/src/analytics/influxdb_wrapper.py +++ b/src/analytics/influxdb_wrapper.py @@ -16,22 +16,23 @@ class InfluxDBWrapper: - def __init__(self, name, field_name, field_value, tags=None): + def __init__(self, name): self.name = name - self.point = Point(name) - - tags = tags or {} - self.record = self._record(field_name, field_value, tags) - + self.records = [] self.write_api = influxdb_client.write_api(write_options=SYNCHRONOUS) + + def add_data_point(self, field_name, field_value, tags=None): + point = Point(self.name) + point.field(field_name, field_value) + + if tags is not None: + for tag_key, tag_value in tags.items(): + point = point.tag(tag_key, tag_value) - def _record(self, field_name, field_value, tags): - for tag_key, tag_value in tags.items(): - self.point = self.point.tag(tag_key, tag_value) - return self.point.field(field_name, field_value) + self.records.append(point) def write(self): - self.write_api.write(bucket=settings.INFLUXDB_BUCKET, record=self.record) + self.write_api.write(bucket=settings.INFLUXDB_BUCKET, record=self.records) def get_events_for_organisation(organisation_id): diff --git a/src/analytics/tests/test_influxdb_wrapper.py b/src/analytics/tests/test_influxdb_wrapper.py index 66777f1148c7..3419a48ecfc1 100644 --- a/src/analytics/tests/test_influxdb_wrapper.py +++ b/src/analytics/tests/test_influxdb_wrapper.py @@ -15,7 +15,8 @@ def test_write(monkeypatch): mock_write_api = mock.MagicMock() mock_influxdb_client.write_api.return_value = mock_write_api - influxdb = InfluxDBWrapper("name", "field_name", "field_value") + influxdb = InfluxDBWrapper("name") + influxdb.add_data_point("field_name", "field_value") # When influxdb.write() diff --git a/src/analytics/tests/test_unit_track.py b/src/analytics/tests/test_unit_track.py index 6fca85125b13..2a5a00bd5604 100644 --- a/src/analytics/tests/test_unit_track.py +++ b/src/analytics/tests/test_unit_track.py @@ -65,7 +65,7 @@ def test_track_request_sends_data_to_influxdb_for_tracked_uris( # Then call_list = MockInfluxDBWrapper.call_args_list assert len(call_list) == 1 - assert call_list[0][1]["tags"]["resource"] == expected_resource + assert mock_influxdb.add_data_point.call_args_list[0][1]["tags"]["resource"] == expected_resource @mock.patch("analytics.track.InfluxDBWrapper") diff --git a/src/analytics/track.py b/src/analytics/track.py index bbc64f8f6f42..0705cc3545ce 100644 --- a/src/analytics/track.py +++ b/src/analytics/track.py @@ -98,5 +98,26 @@ def track_request_influxdb(request): "project_id": environment.project_id } - influxdb = InfluxDBWrapper("api_call", "request_count", 1, tags=tags) + influxdb = InfluxDBWrapper("api_call") + influxdb.add_data_point("request_count", 1, tags=tags) influxdb.write() + + +def track_feature_evaluation_influxdb(environment_id, feature_evaluations): + """ + Sends Feature analytics event data to InfluxDB + + :param environment_id: (int) the id of the environment the feature is being evaluated within + :param feature_evaluations: (dict) A collection of key id / evaluation counts + """ + influxdb = InfluxDBWrapper("feature_evaluation") + + for feature_id, evaluation_count in feature_evaluations.items(): + tags = { + "feature_id": feature_id, + "environment_id": environment_id + } + influxdb.add_data_point("request_count", evaluation_count, tags=tags) + + influxdb.write() + \ No newline at end of file diff --git a/src/analytics/views.py b/src/analytics/views.py new file mode 100644 index 000000000000..d996cd3ab3f5 --- /dev/null +++ b/src/analytics/views.py @@ -0,0 +1,25 @@ +from django.utils.decorators import method_decorator +from drf_yasg2.utils import swagger_auto_schema +from rest_framework import status +from rest_framework.generics import GenericAPIView +from rest_framework.response import Response +from rest_framework.decorators import api_view + +from environments.authentication import EnvironmentKeyAuthentication +from environments.permissions.permissions import EnvironmentKeyPermissions + +from analytics.track import track_feature_evaluation_influxdb + +class SDKAnalyticsFlags(GenericAPIView): + """ + Class to handle flag analytics events + """ + permission_classes = (EnvironmentKeyPermissions,) + authentication_classes = (EnvironmentKeyAuthentication,) + + def post(self, request, *args, **kwargs): + """ + Send flag evaluation events from the SDK back to the API for reporting. + """ + track_feature_evaluation_influxdb(request.environment.id, request.data) + return Response(status=status.HTTP_200_OK) diff --git a/src/api/urls.py.bak b/src/api/urls.py.bak deleted file mode 100644 index c757cb191b6b..000000000000 --- a/src/api/urls.py.bak +++ /dev/null @@ -1,62 +0,0 @@ -from django.conf.urls import url, include -from drf_yasg import openapi -from drf_yasg.views import get_schema_view -from rest_framework import permissions, authentication, routers - -from environments.views import SDKIdentitiesDeprecated, SDKTraitsDeprecated, SDKIdentities, SDKTraits -from features.views import SDKFeatureStates -from organisations.views import chargebee_webhook -from segments.views import SDKSegments - -schema_view = get_schema_view( - openapi.Info( - title="Bullet Train API", - default_version='v1', - description="", - license=openapi.License(name="BSD License"), - contact=openapi.Contact(email="supprt@bullet-train.io"), - ), - public=True, - permission_classes=(permissions.AllowAny,), - authentication_classes=(authentication.SessionAuthentication,) -) - -traits_router = routers.DefaultRouter() -traits_router.register(r'', SDKTraits, basename='sdk-traits') - -current_urls = [ - url(r'^organisations/', include('organisations.urls'), name='organisations'), - url(r'^projects/', include('projects.urls'), name='projects'), - url(r'^environments/', include('environments.urls'), name='environments'), - url(r'^features/', include('features.urls'), name='features'), - url(r'^users/', include('users.urls')), - url(r'^auth/', include('rest_auth.urls')), - url(r'^auth/register/', include('rest_auth.registration.urls')), - url(r'^account/', include('allauth.urls')), - url(r'^e2etests/', include('e2etests.urls')), - url(r'^audit/', include('audit.urls')), - - # Chargebee webhooks - url(r'cb-webhook/', chargebee_webhook, name='chargebee-webhook'), - - # Client SDK urls - url(r'^flags/$', SDKFeatureStates.as_view()), - url(r'^identities/$', SDKIdentities.as_view(), name='sdk-identities'), - url(r'^traits/', include(traits_router.urls), name='traits'), - url(r'^segments/$', SDKSegments.as_view()), - - # API documentation - url(r'^swagger(?P\.json|\.yaml)$', schema_view.without_ui(cache_timeout=0), name='schema-json'), - url(r'^docs/$', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui') -] - -deprecated_urls = [ - url(r'^identities/(?P[-\w@%.]+)/traits/(?P[-\w.]+)', SDKTraitsDeprecated.as_view()), - url(r'^identities/(?P[-\w@%.]+)/', SDKIdentitiesDeprecated.as_view()), - url(r'^flags/(?P[-\w@%.]+)', SDKFeatureStates.as_view()) -] - -urlpatterns = [ - url(r'^v1/', include((deprecated_urls, 'deprecated'))), - url(r'^v1/', include((current_urls, 'v1'))), -] diff --git a/src/api/urls/v1.py b/src/api/urls/v1.py index da3242a9a39a..3b96f4d92acb 100644 --- a/src/api/urls/v1.py +++ b/src/api/urls/v1.py @@ -9,6 +9,7 @@ from features.views import SDKFeatureStates from organisations.views import chargebee_webhook from segments.views import SDKSegments +from analytics.views import SDKAnalyticsFlags schema_view = get_schema_view( openapi.Info( @@ -47,6 +48,7 @@ url(r'^identities/$', SDKIdentities.as_view(), name='sdk-identities'), url(r'^traits/', include(traits_router.urls), name='traits'), url(r'^segments/$', SDKSegments.as_view()), + url(r'^analytics/flags/$', SDKAnalyticsFlags.as_view()), # API documentation url(r'^swagger(?P\.json|\.yaml)$', schema_view.without_ui(cache_timeout=0), name='schema-json'), diff --git a/src/organisations/migrations/0024_organisation_feature_analytics.py b/src/organisations/migrations/0024_organisation_feature_analytics.py new file mode 100644 index 000000000000..8f803c2b09fa --- /dev/null +++ b/src/organisations/migrations/0024_organisation_feature_analytics.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.16 on 2020-11-09 13:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('organisations', '0023_organisation_block_access_to_admin'), + ] + + operations = [ + migrations.AddField( + model_name='organisation', + name='feature_analytics', + field=models.BooleanField(default=False, help_text='Record feature analytics in InfluxDB'), + ), + ] diff --git a/src/organisations/models.py b/src/organisations/models.py index aeedbf536c70..b54ec95dd96a 100644 --- a/src/organisations/models.py +++ b/src/organisations/models.py @@ -31,6 +31,7 @@ class Organisation(models.Model): 'to store trait data for this org\'s identities.') block_access_to_admin = models.BooleanField(default=False, help_text='Enable this to block all the access to admin ' 'interface for the organisation') + feature_analytics = models.BooleanField(default=False, help_text='Record feature analytics in InfluxDB') class Meta: ordering = ['id'] From 523e75db099ed2c7f51c8d7577d641eb46844e3a Mon Sep 17 00:00:00 2001 From: Pavlo Maksymchuk Date: Tue, 17 Nov 2020 09:58:00 +0000 Subject: [PATCH 05/47] Feature/317 add confirm email functionality --- src/custom_auth/serializers.py | 7 ++++++- .../tests/end_to_end/test_custom_auth_integration.py | 6 +++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/custom_auth/serializers.py b/src/custom_auth/serializers.py index b26472805bce..f2eb8c4bc8d3 100644 --- a/src/custom_auth/serializers.py +++ b/src/custom_auth/serializers.py @@ -14,6 +14,11 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.fields["key"] = serializers.SerializerMethodField() - def get_key(self, instance): + class Meta(UserCreateSerializer.Meta): + fields = UserCreateSerializer.Meta.fields + ('is_active',) + read_only_fields = ('is_active',) + + @staticmethod + def get_key(instance): token, _ = Token.objects.get_or_create(user=instance) return token.key diff --git a/src/custom_auth/tests/end_to_end/test_custom_auth_integration.py b/src/custom_auth/tests/end_to_end/test_custom_auth_integration.py index 4a10daa5b345..fab57074fec4 100644 --- a/src/custom_auth/tests/end_to_end/test_custom_auth_integration.py +++ b/src/custom_auth/tests/end_to_end/test_custom_auth_integration.py @@ -13,7 +13,6 @@ class AuthIntegrationTestCase(APITestCase): - test_email = "test@example.com" password = FFAdminUser.objects.make_random_password() @@ -118,13 +117,14 @@ def test_registration_and_login_with_user_activation_flow(self): } # When register - # url = reverse("api-v1:user-activation") - # reverse("api-v1:custom_auth:ffadminuser-activation") register_url = reverse("api-v1:custom_auth:ffadminuser-list") result = self.client.post(register_url, data=register_data, status_code=status.HTTP_201_CREATED) # Then success and account inactive self.assertIn('key', result.data) + self.assertIn('is_active', result.data) + assert result.data['is_active'] == False + new_user = FFAdminUser.objects.latest('id') self.assertEqual(new_user.email, register_data['email']) self.assertFalse(new_user.is_active) From a86be8877eab8c0f3aac3bbb273da6c0b3b5a176 Mon Sep 17 00:00:00 2001 From: Mateusz Szlendak Date: Tue, 17 Nov 2020 09:59:08 +0000 Subject: [PATCH 06/47] unit test fixes added unit test --- Pipfile | 2 ++ src/app/settings/common.py | 9 +++++++++ 2 files changed, 11 insertions(+) diff --git a/Pipfile b/Pipfile index 4fc11f881a33..1cc00cadd5ef 100644 --- a/Pipfile +++ b/Pipfile @@ -52,6 +52,8 @@ influxdb-client = "*" django-ordered-model = "*" django-ses = "*" django-axes = "*" +django-admin-sso = "*" + drf-yasg2 = "*" [pipenv] diff --git a/src/app/settings/common.py b/src/app/settings/common.py index 501eb2360c81..fc4ea5d9f97e 100644 --- a/src/app/settings/common.py +++ b/src/app/settings/common.py @@ -87,6 +87,7 @@ 'djoser', 'django.contrib.sites', 'custom_auth', + 'admin_sso', 'api', 'corsheaders', 'users', @@ -217,6 +218,14 @@ }, ] +AUTHENTICATION_BACKENDS = ( + 'admin_sso.auth.DjangoSSOAuthBackend', + 'django.contrib.auth.backends.ModelBackend', +) + +DJANGO_ADMIN_SSO_OAUTH_CLIENT_ID = env.str('OAUTH_CLIENT_ID', default='') +DJANGO_ADMIN_SSO_OAUTH_CLIENT_SECRET = env.str('OAUTH_CLIENT_SECRET', default='') + # Internationalization # https://docs.djangoproject.com/en/1.9/topics/i18n/ From a3d7985aa75f9e916ab391bf2ebda25e36fd4a6a Mon Sep 17 00:00:00 2001 From: Mateusz Szlendak Date: Tue, 17 Nov 2020 10:32:00 +0000 Subject: [PATCH 07/47] Feature/397 build a sales dashboard --- src/analytics/influxdb_wrapper.py | 101 ++++++++++++++---- src/analytics/tests/test_influxdb_wrapper.py | 66 +++++++++--- src/app/settings/common.py | 1 + src/app/urls.py | 2 + src/organisations/tests/test_views.py | 14 +-- src/organisations/views.py | 8 +- src/sales_dashboard/__init__.py | 0 src/sales_dashboard/apps.py | 5 + src/sales_dashboard/migrations/__init__.py | 0 .../templates/sales_dashboard/base.html | 40 +++++++ .../templates/sales_dashboard/home.html | 77 +++++++++++++ .../sales_dashboard/organisation.html | 96 +++++++++++++++++ src/sales_dashboard/urls.py | 10 ++ src/sales_dashboard/views.py | 58 ++++++++++ .../sales_dashboard/css/bootstrap.min.css | 7 ++ 15 files changed, 445 insertions(+), 40 deletions(-) create mode 100644 src/sales_dashboard/__init__.py create mode 100644 src/sales_dashboard/apps.py create mode 100644 src/sales_dashboard/migrations/__init__.py create mode 100644 src/sales_dashboard/templates/sales_dashboard/base.html create mode 100644 src/sales_dashboard/templates/sales_dashboard/home.html create mode 100644 src/sales_dashboard/templates/sales_dashboard/organisation.html create mode 100644 src/sales_dashboard/urls.py create mode 100644 src/sales_dashboard/views.py create mode 100644 src/users/static/sales_dashboard/css/bootstrap.min.css diff --git a/src/analytics/influxdb_wrapper.py b/src/analytics/influxdb_wrapper.py index b5cd688a333e..7b36af880050 100644 --- a/src/analytics/influxdb_wrapper.py +++ b/src/analytics/influxdb_wrapper.py @@ -1,6 +1,5 @@ from django.conf import settings -from influxdb_client import InfluxDBClient -from influxdb_client import Point +from influxdb_client import InfluxDBClient, Point from influxdb_client.client.write_api import SYNCHRONOUS url = settings.INFLUXDB_URL @@ -8,11 +7,7 @@ influx_org = settings.INFLUXDB_ORG read_bucket = settings.INFLUXDB_BUCKET + "_downsampled_15m" -influxdb_client = InfluxDBClient( - url=url, - token=token, - org=influx_org -) +influxdb_client = InfluxDBClient(url=url, token=token, org=influx_org) class InfluxDBWrapper: @@ -35,6 +30,26 @@ def write(self): self.write_api.write(bucket=settings.INFLUXDB_BUCKET, record=self.records) + @staticmethod + def influx_query_manager( + date_range: str = "30d", + date_stop: str = "now()", + drop_columns: str = "'organisation', 'organisation_id', 'type', 'project', 'project_id'", + filters: str = "|> filter(fn:(r) => r._measurement == 'api_call')", + extra: str = "" + ): + query_api = influxdb_client.query_api() + + query = f'from(bucket:"{read_bucket}")' \ + f' |> range(start: -{date_range}, stop: {date_stop})' \ + f' {filters}' \ + f' |> drop(columns: [{drop_columns}])' \ + f'{extra}' + + result = query_api.query(org=influx_org, query=query) + return result + + def get_events_for_organisation(organisation_id): """ Query influx db for usage for given organisation id @@ -42,21 +57,69 @@ def get_events_for_organisation(organisation_id): :param organisation_id: an id of the organisation to get usage for :return: a number of request counts for organisation """ - query_api = influxdb_client.query_api() - query = ' from(bucket:"%s") \ - |> range(start: -30d, stop: now()) \ - |> filter(fn:(r) => r._measurement == "api_call") \ - |> filter(fn: (r) => r["_field"] == "request_count") \ - |> filter(fn: (r) => r["organisation_id"] == "%s") \ - |> drop(columns: ["organisation", "resource", "project", "project_id"]) \ - |> sum()' % (read_bucket, organisation_id) - - # we should get only one record back - # just in case iterate over and sum them up - result = query_api.query(org=influx_org, query=query) + result = InfluxDBWrapper.influx_query_manager( + filters=f'|> filter(fn:(r) => r._measurement == "api_call") \ + |> filter(fn: (r) => r["_field"] == "request_count") \ + |> filter(fn: (r) => r["organisation_id"] == "{organisation_id}")', + drop_columns='"organisation", "project", "project_id"', + extra="|> sum()" + ) + total = 0 for table in result: for record in table.records: total += record.get_value() return total + + +def get_event_list_for_organisation(organisation_id: int): + """ + Query influx db for usage for given organisation id + + :param organisation_id: an id of the organisation to get usage for + + :return: a number of request counts for organisation in chart.js scheme + """ + results = InfluxDBWrapper.influx_query_manager( + filters=f'|> filter(fn:(r) => r._measurement == "api_call") \ + |> filter(fn: (r) => r["organisation_id"] == "{organisation_id}")', + drop_columns='"organisation", "organisation_id", "type", "project", "project_id"', + extra="|> aggregateWindow(every: 24h, fn: sum)" + ) + dataset = [] + labels = [] + for result in results: + for record in result.records: + dataset.append({ + 't': record.values['_time'].isoformat(), + 'y': record.values['_value'] + }) + labels.append(record.values['_time'].strftime('%Y-%m-%d')) + return dataset, labels + + +def get_multiple_event_list_for_organisation(organisation_id: int): + """ + Query influx db for usage for given organisation id + + :param organisation_id: an id of the organisation to get usage for + + :return: a number of requests for flags, traits, identities + """ + results = InfluxDBWrapper.influx_query_manager( + filters=f'|> filter(fn:(r) => r._measurement == "api_call") \ + |> filter(fn: (r) => r["organisation_id"] == "{organisation_id}")', + drop_columns='"organisation", "organisation_id", "type", "project", "project_id"', + extra="|> aggregateWindow(every: 24h, fn: sum)" + ) + if not results: + return results + + dataset = [{} for _ in range(len(results[0].records))] + + for result in results: + for i, record in enumerate(result.records): + dataset[i][record.values['resource']] = record.values['_value'] + dataset[i]['name'] = record.values['_time'].isoformat() + return dataset diff --git a/src/analytics/tests/test_influxdb_wrapper.py b/src/analytics/tests/test_influxdb_wrapper.py index 3419a48ecfc1..e1993a59492d 100644 --- a/src/analytics/tests/test_influxdb_wrapper.py +++ b/src/analytics/tests/test_influxdb_wrapper.py @@ -4,7 +4,12 @@ import analytics from analytics.influxdb_wrapper import InfluxDBWrapper -from analytics.influxdb_wrapper import get_events_for_organisation +from analytics.influxdb_wrapper import get_events_for_organisation, get_event_list_for_organisation, get_multiple_event_list_for_organisation + +# Given +org_id = 123 +influx_org = settings.INFLUXDB_ORG +read_bucket = settings.INFLUXDB_BUCKET + "_downsampled_15m" def test_write(monkeypatch): @@ -26,18 +31,12 @@ def test_write(monkeypatch): def test_influx_db_query_when_get_events_then_query_api_called(monkeypatch): - # Given - org_id = 123 - influx_org = settings.INFLUXDB_ORG - read_bucket = settings.INFLUXDB_BUCKET + "_downsampled_15m" - query = ' from(bucket:"%s") \ - |> range(start: -30d, stop: now()) \ - |> filter(fn:(r) => r._measurement == "api_call") \ - |> filter(fn: (r) => r["_field"] == "request_count") \ - |> filter(fn: (r) => r["organisation_id"] == "%s") \ - |> drop(columns: ["organisation", "resource", "project", "project_id"]) \ - |> sum()' % (read_bucket, org_id) - + query = f'from(bucket:"{read_bucket}") |> range(start: -30d, stop: now()) ' \ + f'|> filter(fn:(r) => r._measurement == "api_call") ' \ + f'|> filter(fn: (r) => r["_field"] == "request_count") ' \ + f'|> filter(fn: (r) => r["organisation_id"] == "{org_id}") ' \ + f'|> drop(columns: ["organisation", "project", "project_id"])' \ + f'|> sum()' mock_influxdb_client = mock.MagicMock() monkeypatch.setattr(analytics.influxdb_wrapper, "influxdb_client", mock_influxdb_client) @@ -49,3 +48,44 @@ def test_influx_db_query_when_get_events_then_query_api_called(monkeypatch): # Then mock_query_api.query.assert_called_once_with(org=influx_org, query=query) + + +def test_influx_db_query_when_get_events_list_then_query_api_called(monkeypatch): + query = f'from(bucket:"{read_bucket}") ' \ + f'|> range(start: -30d, stop: now()) ' \ + f'|> filter(fn:(r) => r._measurement == "api_call") ' \ + f'|> filter(fn: (r) => r["organisation_id"] == "{org_id}") ' \ + f'|> drop(columns: ["organisation", "organisation_id", "type", "project", "project_id"])' \ + f'|> aggregateWindow(every: 24h, fn: sum)' + mock_influxdb_client = mock.MagicMock() + monkeypatch.setattr(analytics.influxdb_wrapper, "influxdb_client", mock_influxdb_client) + + mock_query_api = mock.MagicMock() + mock_influxdb_client.query_api.return_value = mock_query_api + + # When + get_event_list_for_organisation(org_id) + + # Then + mock_query_api.query.assert_called_once_with(org=influx_org, query=query) + + +def test_influx_db_query_when_get_multiple_events_for_organistation_then_query_api_called(monkeypatch): + query = f'from(bucket:"{read_bucket}") ' \ + '|> range(start: -30d, stop: now()) ' \ + '|> filter(fn:(r) => r._measurement == "api_call") ' \ + f'|> filter(fn: (r) => r["organisation_id"] == "{org_id}") ' \ + '|> drop(columns: ["organisation", "organisation_id", "type", "project", "project_id"])' \ + '|> aggregateWindow(every: 24h, fn: sum)' + mock_influxdb_client = mock.MagicMock() + monkeypatch.setattr(analytics.influxdb_wrapper, "influxdb_client", mock_influxdb_client) + + mock_query_api = mock.MagicMock() + mock_influxdb_client.query_api.return_value = mock_query_api + + # When + get_multiple_event_list_for_organisation(org_id) + + # Then + mock_query_api.query.assert_called_once_with(org=influx_org, query=query) + diff --git a/src/app/settings/common.py b/src/app/settings/common.py index fc4ea5d9f97e..674d9f725f32 100644 --- a/src/app/settings/common.py +++ b/src/app/settings/common.py @@ -93,6 +93,7 @@ 'users', 'organisations', 'projects', + 'sales_dashboard', 'environments', 'environments.permissions', diff --git a/src/app/urls.py b/src/app/urls.py index 7d293b33f9c0..de4bcba635ee 100644 --- a/src/app/urls.py +++ b/src/app/urls.py @@ -10,6 +10,8 @@ url(r'^api/v1/', include('api.urls.v1', namespace='api-v1')), url(r'^admin/', admin.site.urls), url(r'^health', include('health_check.urls', namespace='health')), + url(r'^sales-dashboard/', include('sales_dashboard.urls')), + url(r'', lambda r: HttpResponse("Bullet Train API")), # this url is used to generate email content for the password reset workflow diff --git a/src/organisations/tests/test_views.py b/src/organisations/tests/test_views.py index 8b0fe5970d27..ecc31aa3a8da 100644 --- a/src/organisations/tests/test_views.py +++ b/src/organisations/tests/test_views.py @@ -235,13 +235,13 @@ def test_should_get_usage_for_organisation(self, mock_influxdb_client): influx_org = settings.INFLUXDB_ORG read_bucket = settings.INFLUXDB_BUCKET + "_downsampled_15m" - query = ' from(bucket:"%s") \ - |> range(start: -30d, stop: now()) \ - |> filter(fn:(r) => r._measurement == "api_call") \ - |> filter(fn: (r) => r["_field"] == "request_count") \ - |> filter(fn: (r) => r["organisation_id"] == "%s") \ - |> drop(columns: ["organisation", "resource", "project", "project_id"]) \ - |> sum()' % (read_bucket, organisation.pk) + query = f'from(bucket:"{read_bucket}") ' \ + f'|> range(start: -30d, stop: now()) ' \ + f'|> filter(fn:(r) => r._measurement == "api_call") ' \ + f'|> filter(fn: (r) => r["_field"] == "request_count") ' \ + f'|> filter(fn: (r) => r["organisation_id"] == "{organisation.id}") ' \ + f'|> drop(columns: ["organisation", "project", "project_id"])' \ + f'|> sum()' # When response = self.client.get(url, content_type='application/json') diff --git a/src/organisations/views.py b/src/organisations/views.py index 7a30719b0931..80db5e7d2f79 100644 --- a/src/organisations/views.py +++ b/src/organisations/views.py @@ -21,6 +21,7 @@ from projects.serializers import ProjectSerializer from users.models import Invite from users.serializers import InviteListSerializer, UserIdSerializer +from analytics.influxdb_wrapper import get_multiple_event_list_for_organisation logger = logging.getLogger(__name__) @@ -119,6 +120,12 @@ def get_portal_url(self, request, pk): serializer.is_valid(raise_exception=True) return Response(serializer.data) + @action(detail=True, methods=['GET'], url_path='influx-data') + def get_influx_data(self, request, pk): + event_list = get_multiple_event_list_for_organisation(pk) + + return Response(event_list) + class InviteViewSet(viewsets.ModelViewSet): serializer_class = InviteListSerializer @@ -169,7 +176,6 @@ def chargebee_webhook(request): return Response(status=status.HTTP_200_OK) - class OrganisationWebhookViewSet(viewsets.ModelViewSet): serializer_class = OrganisationWebhookSerializer permission_classes = [IsAuthenticated, NestedOrganisationEntityPermission] diff --git a/src/sales_dashboard/__init__.py b/src/sales_dashboard/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/src/sales_dashboard/apps.py b/src/sales_dashboard/apps.py new file mode 100644 index 000000000000..eaa67869c384 --- /dev/null +++ b/src/sales_dashboard/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class SalesDashboardConfig(AppConfig): + name = 'sales_dashboard' diff --git a/src/sales_dashboard/migrations/__init__.py b/src/sales_dashboard/migrations/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/src/sales_dashboard/templates/sales_dashboard/base.html b/src/sales_dashboard/templates/sales_dashboard/base.html new file mode 100644 index 000000000000..1e0b2b51be4f --- /dev/null +++ b/src/sales_dashboard/templates/sales_dashboard/base.html @@ -0,0 +1,40 @@ + + + + + + + + + {% load static %} + + + + Bullet Train + + + + {% block content %} + {% endblock %} + + + + + + + {% block script %} + {% endblock %} + + diff --git a/src/sales_dashboard/templates/sales_dashboard/home.html b/src/sales_dashboard/templates/sales_dashboard/home.html new file mode 100644 index 000000000000..9fdd7c570833 --- /dev/null +++ b/src/sales_dashboard/templates/sales_dashboard/home.html @@ -0,0 +1,77 @@ +{% extends "sales_dashboard/base.html" %} + +{% block content %} +
+
+ + +
+
+

Organisations

+
+ +
+ + + + + + + + + + + + + + + {% for org in object_list %} + + + + + + + + + + + {% endfor %} + +
IDNameDate RegisteredSeatsProjectsFlagsSegmentsUsers
{{org.id}}{{org.name}}{{org.date_registered}}{{org.users}}{{org.projects}}{{org.flags}}{{org.segments}}{{org.users}}
+ +
+
+
+ +
+{% endblock %} + diff --git a/src/sales_dashboard/templates/sales_dashboard/organisation.html b/src/sales_dashboard/templates/sales_dashboard/organisation.html new file mode 100644 index 000000000000..7223df943913 --- /dev/null +++ b/src/sales_dashboard/templates/sales_dashboard/organisation.html @@ -0,0 +1,96 @@ +{% extends "sales_dashboard/base.html" %} + +{% block content %} +
+
+ + +
+
+

{{organisation.name}}

+
+

Plan: {{ organisation.subscription.plan|default:"Free"}}

+

Seats: {{organisation.subscription.max_seats|default:0 }}

+ +

Users

+
+ + + + + + + + + + + + {% for user in organisation.users.all %} + + + + + + + + {% endfor %} + +
IDNameEmail AddressDate RegisteredLast Logged In
{{ user.id }}{{ user.first_name}}{{user.email}}{{ user.date_joined }}{{ user.last_login }}
+ +
+ +
+
+
+
+
+ +{% endblock %} + + +{% block script %} + +{% endblock %} \ No newline at end of file diff --git a/src/sales_dashboard/urls.py b/src/sales_dashboard/urls.py new file mode 100644 index 000000000000..c82e7a95612b --- /dev/null +++ b/src/sales_dashboard/urls.py @@ -0,0 +1,10 @@ +from django.urls import path +from django.contrib.admin.views.decorators import staff_member_required + +from . import views + +urlpatterns = [ + path('', staff_member_required(views.OrganisationList.as_view()), name='index'), + path('organisations/', views.organisation_info, name='organistation_info'), + +] diff --git a/src/sales_dashboard/views.py b/src/sales_dashboard/views.py new file mode 100644 index 000000000000..7580280129df --- /dev/null +++ b/src/sales_dashboard/views.py @@ -0,0 +1,58 @@ +import json + +from analytics.influxdb_wrapper import get_event_list_for_organisation, get_events_for_organisation +from django.core.paginator import Paginator +from django.contrib.admin.views.decorators import staff_member_required +from django.db.models import Count +from django.http import HttpResponse +from django.template import loader +from django.utils.safestring import mark_safe +from organisations.models import Organisation +from django.shortcuts import get_object_or_404 +from django.views.generic import ListView +OBJECTS_PER_PAGE = 200 + + +class OrganisationList(ListView): + model = Organisation + paginate_by = OBJECTS_PER_PAGE + template_name = 'sales_dashboard/home.html' + + def get_queryset(self): + if 'search' in self.request.GET: + search_term = self.request.GET['search'] + organisations = Organisation.objects.annotate(projects_num=Count('projects')).annotate( + user_num=Count('users')).all().filter(name__icontains=search_term) + else: + organisations = Organisation.objects.annotate(projects_num=Count('projects')).annotate( + user_num=Count('users')).all() + + list_of_organisations = [] + + for organisation in organisations: + list_of_organisations.append({ + 'id': organisation.id, + 'name': organisation.name, + 'date_registered': organisation.created_date, + 'projects': organisation.projects_num, + 'users': organisation.user_num, + 'flags': sum([project.features.count() for project in organisation.projects.all()]), + 'segments': sum([project.segments.count() for project in organisation.projects.all()]), + }) + return list_of_organisations + + +@staff_member_required +def organisation_info(request, organisation_id): + organisation = get_object_or_404(Organisation, pk=organisation_id) + event_list, labels = get_event_list_for_organisation(organisation_id) + template = loader.get_template('sales_dashboard/organisation.html') + context = { + 'organisation': organisation, + 'event_list': mark_safe(json.dumps(event_list)), + 'labels': mark_safe(json.dumps(labels)) + + } + + return HttpResponse(template.render(context, request)) + diff --git a/src/users/static/sales_dashboard/css/bootstrap.min.css b/src/users/static/sales_dashboard/css/bootstrap.min.css new file mode 100644 index 000000000000..21d10bad3e29 --- /dev/null +++ b/src/users/static/sales_dashboard/css/bootstrap.min.css @@ -0,0 +1,7 @@ +/*! + * Bootstrap v4.5.2 (https://getbootstrap.com/) + * Copyright 2011-2020 The Bootstrap Authors + * Copyright 2011-2020 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + */:root{--blue:#007bff;--indigo:#6610f2;--purple:#6f42c1;--pink:#e83e8c;--red:#dc3545;--orange:#fd7e14;--yellow:#ffc107;--green:#28a745;--teal:#20c997;--cyan:#17a2b8;--white:#fff;--gray:#6c757d;--gray-dark:#343a40;--primary:#007bff;--secondary:#6c757d;--success:#28a745;--info:#17a2b8;--warning:#ffc107;--danger:#dc3545;--light:#f8f9fa;--dark:#343a40;--breakpoint-xs:0;--breakpoint-sm:576px;--breakpoint-md:768px;--breakpoint-lg:992px;--breakpoint-xl:1200px;--font-family-sans-serif:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--font-family-monospace:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace}*,::after,::before{box-sizing:border-box}html{font-family:sans-serif;line-height:1.15;-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent}article,aside,figcaption,figure,footer,header,hgroup,main,nav,section{display:block}body{margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";font-size:1rem;font-weight:400;line-height:1.5;color:#212529;text-align:left;background-color:#fff}[tabindex="-1"]:focus:not(:focus-visible){outline:0!important}hr{box-sizing:content-box;height:0;overflow:visible}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem}p{margin-top:0;margin-bottom:1rem}abbr[data-original-title],abbr[title]{text-decoration:underline;-webkit-text-decoration:underline dotted;text-decoration:underline dotted;cursor:help;border-bottom:0;-webkit-text-decoration-skip-ink:none;text-decoration-skip-ink:none}address{margin-bottom:1rem;font-style:normal;line-height:inherit}dl,ol,ul{margin-top:0;margin-bottom:1rem}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}b,strong{font-weight:bolder}small{font-size:80%}sub,sup{position:relative;font-size:75%;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:#007bff;text-decoration:none;background-color:transparent}a:hover{color:#0056b3;text-decoration:underline}a:not([href]):not([class]){color:inherit;text-decoration:none}a:not([href]):not([class]):hover{color:inherit;text-decoration:none}code,kbd,pre,samp{font-family:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;font-size:1em}pre{margin-top:0;margin-bottom:1rem;overflow:auto;-ms-overflow-style:scrollbar}figure{margin:0 0 1rem}img{vertical-align:middle;border-style:none}svg{overflow:hidden;vertical-align:middle}table{border-collapse:collapse}caption{padding-top:.75rem;padding-bottom:.75rem;color:#6c757d;text-align:left;caption-side:bottom}th{text-align:inherit}label{display:inline-block;margin-bottom:.5rem}button{border-radius:0}button:focus{outline:1px dotted;outline:5px auto -webkit-focus-ring-color}button,input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,input{overflow:visible}button,select{text-transform:none}[role=button]{cursor:pointer}select{word-wrap:normal}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]:not(:disabled),[type=reset]:not(:disabled),[type=submit]:not(:disabled),button:not(:disabled){cursor:pointer}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{padding:0;border-style:none}input[type=checkbox],input[type=radio]{box-sizing:border-box;padding:0}textarea{overflow:auto;resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{display:block;width:100%;max-width:100%;padding:0;margin-bottom:.5rem;font-size:1.5rem;line-height:inherit;color:inherit;white-space:normal}progress{vertical-align:baseline}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{outline-offset:-2px;-webkit-appearance:none}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}output{display:inline-block}summary{display:list-item;cursor:pointer}template{display:none}[hidden]{display:none!important}.h1,.h2,.h3,.h4,.h5,.h6,h1,h2,h3,h4,h5,h6{margin-bottom:.5rem;font-weight:500;line-height:1.2}.h1,h1{font-size:2.5rem}.h2,h2{font-size:2rem}.h3,h3{font-size:1.75rem}.h4,h4{font-size:1.5rem}.h5,h5{font-size:1.25rem}.h6,h6{font-size:1rem}.lead{font-size:1.25rem;font-weight:300}.display-1{font-size:6rem;font-weight:300;line-height:1.2}.display-2{font-size:5.5rem;font-weight:300;line-height:1.2}.display-3{font-size:4.5rem;font-weight:300;line-height:1.2}.display-4{font-size:3.5rem;font-weight:300;line-height:1.2}hr{margin-top:1rem;margin-bottom:1rem;border:0;border-top:1px solid rgba(0,0,0,.1)}.small,small{font-size:80%;font-weight:400}.mark,mark{padding:.2em;background-color:#fcf8e3}.list-unstyled{padding-left:0;list-style:none}.list-inline{padding-left:0;list-style:none}.list-inline-item{display:inline-block}.list-inline-item:not(:last-child){margin-right:.5rem}.initialism{font-size:90%;text-transform:uppercase}.blockquote{margin-bottom:1rem;font-size:1.25rem}.blockquote-footer{display:block;font-size:80%;color:#6c757d}.blockquote-footer::before{content:"\2014\00A0"}.img-fluid{max-width:100%;height:auto}.img-thumbnail{padding:.25rem;background-color:#fff;border:1px solid #dee2e6;border-radius:.25rem;max-width:100%;height:auto}.figure{display:inline-block}.figure-img{margin-bottom:.5rem;line-height:1}.figure-caption{font-size:90%;color:#6c757d}code{font-size:87.5%;color:#e83e8c;word-wrap:break-word}a>code{color:inherit}kbd{padding:.2rem .4rem;font-size:87.5%;color:#fff;background-color:#212529;border-radius:.2rem}kbd kbd{padding:0;font-size:100%;font-weight:700}pre{display:block;font-size:87.5%;color:#212529}pre code{font-size:inherit;color:inherit;word-break:normal}.pre-scrollable{max-height:340px;overflow-y:scroll}.container,.container-fluid,.container-lg,.container-md,.container-sm,.container-xl{width:100%;padding-right:15px;padding-left:15px;margin-right:auto;margin-left:auto}@media (min-width:576px){.container,.container-sm{max-width:540px}}@media (min-width:768px){.container,.container-md,.container-sm{max-width:720px}}@media (min-width:992px){.container,.container-lg,.container-md,.container-sm{max-width:960px}}@media (min-width:1200px){.container,.container-lg,.container-md,.container-sm,.container-xl{max-width:1140px}}.row{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;margin-right:-15px;margin-left:-15px}.no-gutters{margin-right:0;margin-left:0}.no-gutters>.col,.no-gutters>[class*=col-]{padding-right:0;padding-left:0}.col,.col-1,.col-10,.col-11,.col-12,.col-2,.col-3,.col-4,.col-5,.col-6,.col-7,.col-8,.col-9,.col-auto,.col-lg,.col-lg-1,.col-lg-10,.col-lg-11,.col-lg-12,.col-lg-2,.col-lg-3,.col-lg-4,.col-lg-5,.col-lg-6,.col-lg-7,.col-lg-8,.col-lg-9,.col-lg-auto,.col-md,.col-md-1,.col-md-10,.col-md-11,.col-md-12,.col-md-2,.col-md-3,.col-md-4,.col-md-5,.col-md-6,.col-md-7,.col-md-8,.col-md-9,.col-md-auto,.col-sm,.col-sm-1,.col-sm-10,.col-sm-11,.col-sm-12,.col-sm-2,.col-sm-3,.col-sm-4,.col-sm-5,.col-sm-6,.col-sm-7,.col-sm-8,.col-sm-9,.col-sm-auto,.col-xl,.col-xl-1,.col-xl-10,.col-xl-11,.col-xl-12,.col-xl-2,.col-xl-3,.col-xl-4,.col-xl-5,.col-xl-6,.col-xl-7,.col-xl-8,.col-xl-9,.col-xl-auto{position:relative;width:100%;padding-right:15px;padding-left:15px}.col{-ms-flex-preferred-size:0;flex-basis:0;-ms-flex-positive:1;flex-grow:1;max-width:100%}.row-cols-1>*{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.row-cols-2>*{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.row-cols-3>*{-ms-flex:0 0 33.333333%;flex:0 0 33.333333%;max-width:33.333333%}.row-cols-4>*{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.row-cols-5>*{-ms-flex:0 0 20%;flex:0 0 20%;max-width:20%}.row-cols-6>*{-ms-flex:0 0 16.666667%;flex:0 0 16.666667%;max-width:16.666667%}.col-auto{-ms-flex:0 0 auto;flex:0 0 auto;width:auto;max-width:100%}.col-1{-ms-flex:0 0 8.333333%;flex:0 0 8.333333%;max-width:8.333333%}.col-2{-ms-flex:0 0 16.666667%;flex:0 0 16.666667%;max-width:16.666667%}.col-3{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-4{-ms-flex:0 0 33.333333%;flex:0 0 33.333333%;max-width:33.333333%}.col-5{-ms-flex:0 0 41.666667%;flex:0 0 41.666667%;max-width:41.666667%}.col-6{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-7{-ms-flex:0 0 58.333333%;flex:0 0 58.333333%;max-width:58.333333%}.col-8{-ms-flex:0 0 66.666667%;flex:0 0 66.666667%;max-width:66.666667%}.col-9{-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-10{-ms-flex:0 0 83.333333%;flex:0 0 83.333333%;max-width:83.333333%}.col-11{-ms-flex:0 0 91.666667%;flex:0 0 91.666667%;max-width:91.666667%}.col-12{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.order-first{-ms-flex-order:-1;order:-1}.order-last{-ms-flex-order:13;order:13}.order-0{-ms-flex-order:0;order:0}.order-1{-ms-flex-order:1;order:1}.order-2{-ms-flex-order:2;order:2}.order-3{-ms-flex-order:3;order:3}.order-4{-ms-flex-order:4;order:4}.order-5{-ms-flex-order:5;order:5}.order-6{-ms-flex-order:6;order:6}.order-7{-ms-flex-order:7;order:7}.order-8{-ms-flex-order:8;order:8}.order-9{-ms-flex-order:9;order:9}.order-10{-ms-flex-order:10;order:10}.order-11{-ms-flex-order:11;order:11}.order-12{-ms-flex-order:12;order:12}.offset-1{margin-left:8.333333%}.offset-2{margin-left:16.666667%}.offset-3{margin-left:25%}.offset-4{margin-left:33.333333%}.offset-5{margin-left:41.666667%}.offset-6{margin-left:50%}.offset-7{margin-left:58.333333%}.offset-8{margin-left:66.666667%}.offset-9{margin-left:75%}.offset-10{margin-left:83.333333%}.offset-11{margin-left:91.666667%}@media (min-width:576px){.col-sm{-ms-flex-preferred-size:0;flex-basis:0;-ms-flex-positive:1;flex-grow:1;max-width:100%}.row-cols-sm-1>*{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.row-cols-sm-2>*{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.row-cols-sm-3>*{-ms-flex:0 0 33.333333%;flex:0 0 33.333333%;max-width:33.333333%}.row-cols-sm-4>*{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.row-cols-sm-5>*{-ms-flex:0 0 20%;flex:0 0 20%;max-width:20%}.row-cols-sm-6>*{-ms-flex:0 0 16.666667%;flex:0 0 16.666667%;max-width:16.666667%}.col-sm-auto{-ms-flex:0 0 auto;flex:0 0 auto;width:auto;max-width:100%}.col-sm-1{-ms-flex:0 0 8.333333%;flex:0 0 8.333333%;max-width:8.333333%}.col-sm-2{-ms-flex:0 0 16.666667%;flex:0 0 16.666667%;max-width:16.666667%}.col-sm-3{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-sm-4{-ms-flex:0 0 33.333333%;flex:0 0 33.333333%;max-width:33.333333%}.col-sm-5{-ms-flex:0 0 41.666667%;flex:0 0 41.666667%;max-width:41.666667%}.col-sm-6{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-sm-7{-ms-flex:0 0 58.333333%;flex:0 0 58.333333%;max-width:58.333333%}.col-sm-8{-ms-flex:0 0 66.666667%;flex:0 0 66.666667%;max-width:66.666667%}.col-sm-9{-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-sm-10{-ms-flex:0 0 83.333333%;flex:0 0 83.333333%;max-width:83.333333%}.col-sm-11{-ms-flex:0 0 91.666667%;flex:0 0 91.666667%;max-width:91.666667%}.col-sm-12{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.order-sm-first{-ms-flex-order:-1;order:-1}.order-sm-last{-ms-flex-order:13;order:13}.order-sm-0{-ms-flex-order:0;order:0}.order-sm-1{-ms-flex-order:1;order:1}.order-sm-2{-ms-flex-order:2;order:2}.order-sm-3{-ms-flex-order:3;order:3}.order-sm-4{-ms-flex-order:4;order:4}.order-sm-5{-ms-flex-order:5;order:5}.order-sm-6{-ms-flex-order:6;order:6}.order-sm-7{-ms-flex-order:7;order:7}.order-sm-8{-ms-flex-order:8;order:8}.order-sm-9{-ms-flex-order:9;order:9}.order-sm-10{-ms-flex-order:10;order:10}.order-sm-11{-ms-flex-order:11;order:11}.order-sm-12{-ms-flex-order:12;order:12}.offset-sm-0{margin-left:0}.offset-sm-1{margin-left:8.333333%}.offset-sm-2{margin-left:16.666667%}.offset-sm-3{margin-left:25%}.offset-sm-4{margin-left:33.333333%}.offset-sm-5{margin-left:41.666667%}.offset-sm-6{margin-left:50%}.offset-sm-7{margin-left:58.333333%}.offset-sm-8{margin-left:66.666667%}.offset-sm-9{margin-left:75%}.offset-sm-10{margin-left:83.333333%}.offset-sm-11{margin-left:91.666667%}}@media (min-width:768px){.col-md{-ms-flex-preferred-size:0;flex-basis:0;-ms-flex-positive:1;flex-grow:1;max-width:100%}.row-cols-md-1>*{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.row-cols-md-2>*{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.row-cols-md-3>*{-ms-flex:0 0 33.333333%;flex:0 0 33.333333%;max-width:33.333333%}.row-cols-md-4>*{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.row-cols-md-5>*{-ms-flex:0 0 20%;flex:0 0 20%;max-width:20%}.row-cols-md-6>*{-ms-flex:0 0 16.666667%;flex:0 0 16.666667%;max-width:16.666667%}.col-md-auto{-ms-flex:0 0 auto;flex:0 0 auto;width:auto;max-width:100%}.col-md-1{-ms-flex:0 0 8.333333%;flex:0 0 8.333333%;max-width:8.333333%}.col-md-2{-ms-flex:0 0 16.666667%;flex:0 0 16.666667%;max-width:16.666667%}.col-md-3{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-md-4{-ms-flex:0 0 33.333333%;flex:0 0 33.333333%;max-width:33.333333%}.col-md-5{-ms-flex:0 0 41.666667%;flex:0 0 41.666667%;max-width:41.666667%}.col-md-6{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-md-7{-ms-flex:0 0 58.333333%;flex:0 0 58.333333%;max-width:58.333333%}.col-md-8{-ms-flex:0 0 66.666667%;flex:0 0 66.666667%;max-width:66.666667%}.col-md-9{-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-md-10{-ms-flex:0 0 83.333333%;flex:0 0 83.333333%;max-width:83.333333%}.col-md-11{-ms-flex:0 0 91.666667%;flex:0 0 91.666667%;max-width:91.666667%}.col-md-12{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.order-md-first{-ms-flex-order:-1;order:-1}.order-md-last{-ms-flex-order:13;order:13}.order-md-0{-ms-flex-order:0;order:0}.order-md-1{-ms-flex-order:1;order:1}.order-md-2{-ms-flex-order:2;order:2}.order-md-3{-ms-flex-order:3;order:3}.order-md-4{-ms-flex-order:4;order:4}.order-md-5{-ms-flex-order:5;order:5}.order-md-6{-ms-flex-order:6;order:6}.order-md-7{-ms-flex-order:7;order:7}.order-md-8{-ms-flex-order:8;order:8}.order-md-9{-ms-flex-order:9;order:9}.order-md-10{-ms-flex-order:10;order:10}.order-md-11{-ms-flex-order:11;order:11}.order-md-12{-ms-flex-order:12;order:12}.offset-md-0{margin-left:0}.offset-md-1{margin-left:8.333333%}.offset-md-2{margin-left:16.666667%}.offset-md-3{margin-left:25%}.offset-md-4{margin-left:33.333333%}.offset-md-5{margin-left:41.666667%}.offset-md-6{margin-left:50%}.offset-md-7{margin-left:58.333333%}.offset-md-8{margin-left:66.666667%}.offset-md-9{margin-left:75%}.offset-md-10{margin-left:83.333333%}.offset-md-11{margin-left:91.666667%}}@media (min-width:992px){.col-lg{-ms-flex-preferred-size:0;flex-basis:0;-ms-flex-positive:1;flex-grow:1;max-width:100%}.row-cols-lg-1>*{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.row-cols-lg-2>*{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.row-cols-lg-3>*{-ms-flex:0 0 33.333333%;flex:0 0 33.333333%;max-width:33.333333%}.row-cols-lg-4>*{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.row-cols-lg-5>*{-ms-flex:0 0 20%;flex:0 0 20%;max-width:20%}.row-cols-lg-6>*{-ms-flex:0 0 16.666667%;flex:0 0 16.666667%;max-width:16.666667%}.col-lg-auto{-ms-flex:0 0 auto;flex:0 0 auto;width:auto;max-width:100%}.col-lg-1{-ms-flex:0 0 8.333333%;flex:0 0 8.333333%;max-width:8.333333%}.col-lg-2{-ms-flex:0 0 16.666667%;flex:0 0 16.666667%;max-width:16.666667%}.col-lg-3{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-lg-4{-ms-flex:0 0 33.333333%;flex:0 0 33.333333%;max-width:33.333333%}.col-lg-5{-ms-flex:0 0 41.666667%;flex:0 0 41.666667%;max-width:41.666667%}.col-lg-6{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-lg-7{-ms-flex:0 0 58.333333%;flex:0 0 58.333333%;max-width:58.333333%}.col-lg-8{-ms-flex:0 0 66.666667%;flex:0 0 66.666667%;max-width:66.666667%}.col-lg-9{-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-lg-10{-ms-flex:0 0 83.333333%;flex:0 0 83.333333%;max-width:83.333333%}.col-lg-11{-ms-flex:0 0 91.666667%;flex:0 0 91.666667%;max-width:91.666667%}.col-lg-12{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.order-lg-first{-ms-flex-order:-1;order:-1}.order-lg-last{-ms-flex-order:13;order:13}.order-lg-0{-ms-flex-order:0;order:0}.order-lg-1{-ms-flex-order:1;order:1}.order-lg-2{-ms-flex-order:2;order:2}.order-lg-3{-ms-flex-order:3;order:3}.order-lg-4{-ms-flex-order:4;order:4}.order-lg-5{-ms-flex-order:5;order:5}.order-lg-6{-ms-flex-order:6;order:6}.order-lg-7{-ms-flex-order:7;order:7}.order-lg-8{-ms-flex-order:8;order:8}.order-lg-9{-ms-flex-order:9;order:9}.order-lg-10{-ms-flex-order:10;order:10}.order-lg-11{-ms-flex-order:11;order:11}.order-lg-12{-ms-flex-order:12;order:12}.offset-lg-0{margin-left:0}.offset-lg-1{margin-left:8.333333%}.offset-lg-2{margin-left:16.666667%}.offset-lg-3{margin-left:25%}.offset-lg-4{margin-left:33.333333%}.offset-lg-5{margin-left:41.666667%}.offset-lg-6{margin-left:50%}.offset-lg-7{margin-left:58.333333%}.offset-lg-8{margin-left:66.666667%}.offset-lg-9{margin-left:75%}.offset-lg-10{margin-left:83.333333%}.offset-lg-11{margin-left:91.666667%}}@media (min-width:1200px){.col-xl{-ms-flex-preferred-size:0;flex-basis:0;-ms-flex-positive:1;flex-grow:1;max-width:100%}.row-cols-xl-1>*{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.row-cols-xl-2>*{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.row-cols-xl-3>*{-ms-flex:0 0 33.333333%;flex:0 0 33.333333%;max-width:33.333333%}.row-cols-xl-4>*{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.row-cols-xl-5>*{-ms-flex:0 0 20%;flex:0 0 20%;max-width:20%}.row-cols-xl-6>*{-ms-flex:0 0 16.666667%;flex:0 0 16.666667%;max-width:16.666667%}.col-xl-auto{-ms-flex:0 0 auto;flex:0 0 auto;width:auto;max-width:100%}.col-xl-1{-ms-flex:0 0 8.333333%;flex:0 0 8.333333%;max-width:8.333333%}.col-xl-2{-ms-flex:0 0 16.666667%;flex:0 0 16.666667%;max-width:16.666667%}.col-xl-3{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-xl-4{-ms-flex:0 0 33.333333%;flex:0 0 33.333333%;max-width:33.333333%}.col-xl-5{-ms-flex:0 0 41.666667%;flex:0 0 41.666667%;max-width:41.666667%}.col-xl-6{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-xl-7{-ms-flex:0 0 58.333333%;flex:0 0 58.333333%;max-width:58.333333%}.col-xl-8{-ms-flex:0 0 66.666667%;flex:0 0 66.666667%;max-width:66.666667%}.col-xl-9{-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-xl-10{-ms-flex:0 0 83.333333%;flex:0 0 83.333333%;max-width:83.333333%}.col-xl-11{-ms-flex:0 0 91.666667%;flex:0 0 91.666667%;max-width:91.666667%}.col-xl-12{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.order-xl-first{-ms-flex-order:-1;order:-1}.order-xl-last{-ms-flex-order:13;order:13}.order-xl-0{-ms-flex-order:0;order:0}.order-xl-1{-ms-flex-order:1;order:1}.order-xl-2{-ms-flex-order:2;order:2}.order-xl-3{-ms-flex-order:3;order:3}.order-xl-4{-ms-flex-order:4;order:4}.order-xl-5{-ms-flex-order:5;order:5}.order-xl-6{-ms-flex-order:6;order:6}.order-xl-7{-ms-flex-order:7;order:7}.order-xl-8{-ms-flex-order:8;order:8}.order-xl-9{-ms-flex-order:9;order:9}.order-xl-10{-ms-flex-order:10;order:10}.order-xl-11{-ms-flex-order:11;order:11}.order-xl-12{-ms-flex-order:12;order:12}.offset-xl-0{margin-left:0}.offset-xl-1{margin-left:8.333333%}.offset-xl-2{margin-left:16.666667%}.offset-xl-3{margin-left:25%}.offset-xl-4{margin-left:33.333333%}.offset-xl-5{margin-left:41.666667%}.offset-xl-6{margin-left:50%}.offset-xl-7{margin-left:58.333333%}.offset-xl-8{margin-left:66.666667%}.offset-xl-9{margin-left:75%}.offset-xl-10{margin-left:83.333333%}.offset-xl-11{margin-left:91.666667%}}.table{width:100%;margin-bottom:1rem;color:#212529}.table td,.table th{padding:.75rem;vertical-align:top;border-top:1px solid #dee2e6}.table thead th{vertical-align:bottom;border-bottom:2px solid #dee2e6}.table tbody+tbody{border-top:2px solid #dee2e6}.table-sm td,.table-sm th{padding:.3rem}.table-bordered{border:1px solid #dee2e6}.table-bordered td,.table-bordered th{border:1px solid #dee2e6}.table-bordered thead td,.table-bordered thead th{border-bottom-width:2px}.table-borderless tbody+tbody,.table-borderless td,.table-borderless th,.table-borderless thead th{border:0}.table-striped tbody tr:nth-of-type(odd){background-color:rgba(0,0,0,.05)}.table-hover tbody tr:hover{color:#212529;background-color:rgba(0,0,0,.075)}.table-primary,.table-primary>td,.table-primary>th{background-color:#b8daff}.table-primary tbody+tbody,.table-primary td,.table-primary th,.table-primary thead th{border-color:#7abaff}.table-hover .table-primary:hover{background-color:#9fcdff}.table-hover .table-primary:hover>td,.table-hover .table-primary:hover>th{background-color:#9fcdff}.table-secondary,.table-secondary>td,.table-secondary>th{background-color:#d6d8db}.table-secondary tbody+tbody,.table-secondary td,.table-secondary th,.table-secondary thead th{border-color:#b3b7bb}.table-hover .table-secondary:hover{background-color:#c8cbcf}.table-hover .table-secondary:hover>td,.table-hover .table-secondary:hover>th{background-color:#c8cbcf}.table-success,.table-success>td,.table-success>th{background-color:#c3e6cb}.table-success tbody+tbody,.table-success td,.table-success th,.table-success thead th{border-color:#8fd19e}.table-hover .table-success:hover{background-color:#b1dfbb}.table-hover .table-success:hover>td,.table-hover .table-success:hover>th{background-color:#b1dfbb}.table-info,.table-info>td,.table-info>th{background-color:#bee5eb}.table-info tbody+tbody,.table-info td,.table-info th,.table-info thead th{border-color:#86cfda}.table-hover .table-info:hover{background-color:#abdde5}.table-hover .table-info:hover>td,.table-hover .table-info:hover>th{background-color:#abdde5}.table-warning,.table-warning>td,.table-warning>th{background-color:#ffeeba}.table-warning tbody+tbody,.table-warning td,.table-warning th,.table-warning thead th{border-color:#ffdf7e}.table-hover .table-warning:hover{background-color:#ffe8a1}.table-hover .table-warning:hover>td,.table-hover .table-warning:hover>th{background-color:#ffe8a1}.table-danger,.table-danger>td,.table-danger>th{background-color:#f5c6cb}.table-danger tbody+tbody,.table-danger td,.table-danger th,.table-danger thead th{border-color:#ed969e}.table-hover .table-danger:hover{background-color:#f1b0b7}.table-hover .table-danger:hover>td,.table-hover .table-danger:hover>th{background-color:#f1b0b7}.table-light,.table-light>td,.table-light>th{background-color:#fdfdfe}.table-light tbody+tbody,.table-light td,.table-light th,.table-light thead th{border-color:#fbfcfc}.table-hover .table-light:hover{background-color:#ececf6}.table-hover .table-light:hover>td,.table-hover .table-light:hover>th{background-color:#ececf6}.table-dark,.table-dark>td,.table-dark>th{background-color:#c6c8ca}.table-dark tbody+tbody,.table-dark td,.table-dark th,.table-dark thead th{border-color:#95999c}.table-hover .table-dark:hover{background-color:#b9bbbe}.table-hover .table-dark:hover>td,.table-hover .table-dark:hover>th{background-color:#b9bbbe}.table-active,.table-active>td,.table-active>th{background-color:rgba(0,0,0,.075)}.table-hover .table-active:hover{background-color:rgba(0,0,0,.075)}.table-hover .table-active:hover>td,.table-hover .table-active:hover>th{background-color:rgba(0,0,0,.075)}.table .thead-dark th{color:#fff;background-color:#343a40;border-color:#454d55}.table .thead-light th{color:#495057;background-color:#e9ecef;border-color:#dee2e6}.table-dark{color:#fff;background-color:#343a40}.table-dark td,.table-dark th,.table-dark thead th{border-color:#454d55}.table-dark.table-bordered{border:0}.table-dark.table-striped tbody tr:nth-of-type(odd){background-color:rgba(255,255,255,.05)}.table-dark.table-hover tbody tr:hover{color:#fff;background-color:rgba(255,255,255,.075)}@media (max-width:575.98px){.table-responsive-sm{display:block;width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch}.table-responsive-sm>.table-bordered{border:0}}@media (max-width:767.98px){.table-responsive-md{display:block;width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch}.table-responsive-md>.table-bordered{border:0}}@media (max-width:991.98px){.table-responsive-lg{display:block;width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch}.table-responsive-lg>.table-bordered{border:0}}@media (max-width:1199.98px){.table-responsive-xl{display:block;width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch}.table-responsive-xl>.table-bordered{border:0}}.table-responsive{display:block;width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch}.table-responsive>.table-bordered{border:0}.form-control{display:block;width:100%;height:calc(1.5em + .75rem + 2px);padding:.375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:#495057;background-color:#fff;background-clip:padding-box;border:1px solid #ced4da;border-radius:.25rem;transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-control{transition:none}}.form-control::-ms-expand{background-color:transparent;border:0}.form-control:-moz-focusring{color:transparent;text-shadow:0 0 0 #495057}.form-control:focus{color:#495057;background-color:#fff;border-color:#80bdff;outline:0;box-shadow:0 0 0 .2rem rgba(0,123,255,.25)}.form-control::-webkit-input-placeholder{color:#6c757d;opacity:1}.form-control::-moz-placeholder{color:#6c757d;opacity:1}.form-control:-ms-input-placeholder{color:#6c757d;opacity:1}.form-control::-ms-input-placeholder{color:#6c757d;opacity:1}.form-control::placeholder{color:#6c757d;opacity:1}.form-control:disabled,.form-control[readonly]{background-color:#e9ecef;opacity:1}input[type=date].form-control,input[type=datetime-local].form-control,input[type=month].form-control,input[type=time].form-control{-webkit-appearance:none;-moz-appearance:none;appearance:none}select.form-control:focus::-ms-value{color:#495057;background-color:#fff}.form-control-file,.form-control-range{display:block;width:100%}.col-form-label{padding-top:calc(.375rem + 1px);padding-bottom:calc(.375rem + 1px);margin-bottom:0;font-size:inherit;line-height:1.5}.col-form-label-lg{padding-top:calc(.5rem + 1px);padding-bottom:calc(.5rem + 1px);font-size:1.25rem;line-height:1.5}.col-form-label-sm{padding-top:calc(.25rem + 1px);padding-bottom:calc(.25rem + 1px);font-size:.875rem;line-height:1.5}.form-control-plaintext{display:block;width:100%;padding:.375rem 0;margin-bottom:0;font-size:1rem;line-height:1.5;color:#212529;background-color:transparent;border:solid transparent;border-width:1px 0}.form-control-plaintext.form-control-lg,.form-control-plaintext.form-control-sm{padding-right:0;padding-left:0}.form-control-sm{height:calc(1.5em + .5rem + 2px);padding:.25rem .5rem;font-size:.875rem;line-height:1.5;border-radius:.2rem}.form-control-lg{height:calc(1.5em + 1rem + 2px);padding:.5rem 1rem;font-size:1.25rem;line-height:1.5;border-radius:.3rem}select.form-control[multiple],select.form-control[size]{height:auto}textarea.form-control{height:auto}.form-group{margin-bottom:1rem}.form-text{display:block;margin-top:.25rem}.form-row{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;margin-right:-5px;margin-left:-5px}.form-row>.col,.form-row>[class*=col-]{padding-right:5px;padding-left:5px}.form-check{position:relative;display:block;padding-left:1.25rem}.form-check-input{position:absolute;margin-top:.3rem;margin-left:-1.25rem}.form-check-input:disabled~.form-check-label,.form-check-input[disabled]~.form-check-label{color:#6c757d}.form-check-label{margin-bottom:0}.form-check-inline{display:-ms-inline-flexbox;display:inline-flex;-ms-flex-align:center;align-items:center;padding-left:0;margin-right:.75rem}.form-check-inline .form-check-input{position:static;margin-top:0;margin-right:.3125rem;margin-left:0}.valid-feedback{display:none;width:100%;margin-top:.25rem;font-size:80%;color:#28a745}.valid-tooltip{position:absolute;top:100%;left:0;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:.875rem;line-height:1.5;color:#fff;background-color:rgba(40,167,69,.9);border-radius:.25rem}.is-valid~.valid-feedback,.is-valid~.valid-tooltip,.was-validated :valid~.valid-feedback,.was-validated :valid~.valid-tooltip{display:block}.form-control.is-valid,.was-validated .form-control:valid{border-color:#28a745;padding-right:calc(1.5em + .75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 8 8'%3e%3cpath fill='%2328a745' d='M2.3 6.73L.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right calc(.375em + .1875rem) center;background-size:calc(.75em + .375rem) calc(.75em + .375rem)}.form-control.is-valid:focus,.was-validated .form-control:valid:focus{border-color:#28a745;box-shadow:0 0 0 .2rem rgba(40,167,69,.25)}.was-validated textarea.form-control:valid,textarea.form-control.is-valid{padding-right:calc(1.5em + .75rem);background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem)}.custom-select.is-valid,.was-validated .custom-select:valid{border-color:#28a745;padding-right:calc(.75em + 2.3125rem);background:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='4' height='5' viewBox='0 0 4 5'%3e%3cpath fill='%23343a40' d='M2 0L0 2h4zm0 5L0 3h4z'/%3e%3c/svg%3e") no-repeat right .75rem center/8px 10px,url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 8 8'%3e%3cpath fill='%2328a745' d='M2.3 6.73L.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e") #fff no-repeat center right 1.75rem/calc(.75em + .375rem) calc(.75em + .375rem)}.custom-select.is-valid:focus,.was-validated .custom-select:valid:focus{border-color:#28a745;box-shadow:0 0 0 .2rem rgba(40,167,69,.25)}.form-check-input.is-valid~.form-check-label,.was-validated .form-check-input:valid~.form-check-label{color:#28a745}.form-check-input.is-valid~.valid-feedback,.form-check-input.is-valid~.valid-tooltip,.was-validated .form-check-input:valid~.valid-feedback,.was-validated .form-check-input:valid~.valid-tooltip{display:block}.custom-control-input.is-valid~.custom-control-label,.was-validated .custom-control-input:valid~.custom-control-label{color:#28a745}.custom-control-input.is-valid~.custom-control-label::before,.was-validated .custom-control-input:valid~.custom-control-label::before{border-color:#28a745}.custom-control-input.is-valid:checked~.custom-control-label::before,.was-validated .custom-control-input:valid:checked~.custom-control-label::before{border-color:#34ce57;background-color:#34ce57}.custom-control-input.is-valid:focus~.custom-control-label::before,.was-validated .custom-control-input:valid:focus~.custom-control-label::before{box-shadow:0 0 0 .2rem rgba(40,167,69,.25)}.custom-control-input.is-valid:focus:not(:checked)~.custom-control-label::before,.was-validated .custom-control-input:valid:focus:not(:checked)~.custom-control-label::before{border-color:#28a745}.custom-file-input.is-valid~.custom-file-label,.was-validated .custom-file-input:valid~.custom-file-label{border-color:#28a745}.custom-file-input.is-valid:focus~.custom-file-label,.was-validated .custom-file-input:valid:focus~.custom-file-label{border-color:#28a745;box-shadow:0 0 0 .2rem rgba(40,167,69,.25)}.invalid-feedback{display:none;width:100%;margin-top:.25rem;font-size:80%;color:#dc3545}.invalid-tooltip{position:absolute;top:100%;left:0;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:.875rem;line-height:1.5;color:#fff;background-color:rgba(220,53,69,.9);border-radius:.25rem}.is-invalid~.invalid-feedback,.is-invalid~.invalid-tooltip,.was-validated :invalid~.invalid-feedback,.was-validated :invalid~.invalid-tooltip{display:block}.form-control.is-invalid,.was-validated .form-control:invalid{border-color:#dc3545;padding-right:calc(1.5em + .75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%23dc3545' viewBox='0 0 12 12'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right calc(.375em + .1875rem) center;background-size:calc(.75em + .375rem) calc(.75em + .375rem)}.form-control.is-invalid:focus,.was-validated .form-control:invalid:focus{border-color:#dc3545;box-shadow:0 0 0 .2rem rgba(220,53,69,.25)}.was-validated textarea.form-control:invalid,textarea.form-control.is-invalid{padding-right:calc(1.5em + .75rem);background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem)}.custom-select.is-invalid,.was-validated .custom-select:invalid{border-color:#dc3545;padding-right:calc(.75em + 2.3125rem);background:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='4' height='5' viewBox='0 0 4 5'%3e%3cpath fill='%23343a40' d='M2 0L0 2h4zm0 5L0 3h4z'/%3e%3c/svg%3e") no-repeat right .75rem center/8px 10px,url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%23dc3545' viewBox='0 0 12 12'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e") #fff no-repeat center right 1.75rem/calc(.75em + .375rem) calc(.75em + .375rem)}.custom-select.is-invalid:focus,.was-validated .custom-select:invalid:focus{border-color:#dc3545;box-shadow:0 0 0 .2rem rgba(220,53,69,.25)}.form-check-input.is-invalid~.form-check-label,.was-validated .form-check-input:invalid~.form-check-label{color:#dc3545}.form-check-input.is-invalid~.invalid-feedback,.form-check-input.is-invalid~.invalid-tooltip,.was-validated .form-check-input:invalid~.invalid-feedback,.was-validated .form-check-input:invalid~.invalid-tooltip{display:block}.custom-control-input.is-invalid~.custom-control-label,.was-validated .custom-control-input:invalid~.custom-control-label{color:#dc3545}.custom-control-input.is-invalid~.custom-control-label::before,.was-validated .custom-control-input:invalid~.custom-control-label::before{border-color:#dc3545}.custom-control-input.is-invalid:checked~.custom-control-label::before,.was-validated .custom-control-input:invalid:checked~.custom-control-label::before{border-color:#e4606d;background-color:#e4606d}.custom-control-input.is-invalid:focus~.custom-control-label::before,.was-validated .custom-control-input:invalid:focus~.custom-control-label::before{box-shadow:0 0 0 .2rem rgba(220,53,69,.25)}.custom-control-input.is-invalid:focus:not(:checked)~.custom-control-label::before,.was-validated .custom-control-input:invalid:focus:not(:checked)~.custom-control-label::before{border-color:#dc3545}.custom-file-input.is-invalid~.custom-file-label,.was-validated .custom-file-input:invalid~.custom-file-label{border-color:#dc3545}.custom-file-input.is-invalid:focus~.custom-file-label,.was-validated .custom-file-input:invalid:focus~.custom-file-label{border-color:#dc3545;box-shadow:0 0 0 .2rem rgba(220,53,69,.25)}.form-inline{display:-ms-flexbox;display:flex;-ms-flex-flow:row wrap;flex-flow:row wrap;-ms-flex-align:center;align-items:center}.form-inline .form-check{width:100%}@media (min-width:576px){.form-inline label{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;-ms-flex-pack:center;justify-content:center;margin-bottom:0}.form-inline .form-group{display:-ms-flexbox;display:flex;-ms-flex:0 0 auto;flex:0 0 auto;-ms-flex-flow:row wrap;flex-flow:row wrap;-ms-flex-align:center;align-items:center;margin-bottom:0}.form-inline .form-control{display:inline-block;width:auto;vertical-align:middle}.form-inline .form-control-plaintext{display:inline-block}.form-inline .custom-select,.form-inline .input-group{width:auto}.form-inline .form-check{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;-ms-flex-pack:center;justify-content:center;width:auto;padding-left:0}.form-inline .form-check-input{position:relative;-ms-flex-negative:0;flex-shrink:0;margin-top:0;margin-right:.25rem;margin-left:0}.form-inline .custom-control{-ms-flex-align:center;align-items:center;-ms-flex-pack:center;justify-content:center}.form-inline .custom-control-label{margin-bottom:0}}.btn{display:inline-block;font-weight:400;color:#212529;text-align:center;vertical-align:middle;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;background-color:transparent;border:1px solid transparent;padding:.375rem .75rem;font-size:1rem;line-height:1.5;border-radius:.25rem;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.btn{transition:none}}.btn:hover{color:#212529;text-decoration:none}.btn.focus,.btn:focus{outline:0;box-shadow:0 0 0 .2rem rgba(0,123,255,.25)}.btn.disabled,.btn:disabled{opacity:.65}.btn:not(:disabled):not(.disabled){cursor:pointer}a.btn.disabled,fieldset:disabled a.btn{pointer-events:none}.btn-primary{color:#fff;background-color:#007bff;border-color:#007bff}.btn-primary:hover{color:#fff;background-color:#0069d9;border-color:#0062cc}.btn-primary.focus,.btn-primary:focus{color:#fff;background-color:#0069d9;border-color:#0062cc;box-shadow:0 0 0 .2rem rgba(38,143,255,.5)}.btn-primary.disabled,.btn-primary:disabled{color:#fff;background-color:#007bff;border-color:#007bff}.btn-primary:not(:disabled):not(.disabled).active,.btn-primary:not(:disabled):not(.disabled):active,.show>.btn-primary.dropdown-toggle{color:#fff;background-color:#0062cc;border-color:#005cbf}.btn-primary:not(:disabled):not(.disabled).active:focus,.btn-primary:not(:disabled):not(.disabled):active:focus,.show>.btn-primary.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(38,143,255,.5)}.btn-secondary{color:#fff;background-color:#6c757d;border-color:#6c757d}.btn-secondary:hover{color:#fff;background-color:#5a6268;border-color:#545b62}.btn-secondary.focus,.btn-secondary:focus{color:#fff;background-color:#5a6268;border-color:#545b62;box-shadow:0 0 0 .2rem rgba(130,138,145,.5)}.btn-secondary.disabled,.btn-secondary:disabled{color:#fff;background-color:#6c757d;border-color:#6c757d}.btn-secondary:not(:disabled):not(.disabled).active,.btn-secondary:not(:disabled):not(.disabled):active,.show>.btn-secondary.dropdown-toggle{color:#fff;background-color:#545b62;border-color:#4e555b}.btn-secondary:not(:disabled):not(.disabled).active:focus,.btn-secondary:not(:disabled):not(.disabled):active:focus,.show>.btn-secondary.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(130,138,145,.5)}.btn-success{color:#fff;background-color:#28a745;border-color:#28a745}.btn-success:hover{color:#fff;background-color:#218838;border-color:#1e7e34}.btn-success.focus,.btn-success:focus{color:#fff;background-color:#218838;border-color:#1e7e34;box-shadow:0 0 0 .2rem rgba(72,180,97,.5)}.btn-success.disabled,.btn-success:disabled{color:#fff;background-color:#28a745;border-color:#28a745}.btn-success:not(:disabled):not(.disabled).active,.btn-success:not(:disabled):not(.disabled):active,.show>.btn-success.dropdown-toggle{color:#fff;background-color:#1e7e34;border-color:#1c7430}.btn-success:not(:disabled):not(.disabled).active:focus,.btn-success:not(:disabled):not(.disabled):active:focus,.show>.btn-success.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(72,180,97,.5)}.btn-info{color:#fff;background-color:#17a2b8;border-color:#17a2b8}.btn-info:hover{color:#fff;background-color:#138496;border-color:#117a8b}.btn-info.focus,.btn-info:focus{color:#fff;background-color:#138496;border-color:#117a8b;box-shadow:0 0 0 .2rem rgba(58,176,195,.5)}.btn-info.disabled,.btn-info:disabled{color:#fff;background-color:#17a2b8;border-color:#17a2b8}.btn-info:not(:disabled):not(.disabled).active,.btn-info:not(:disabled):not(.disabled):active,.show>.btn-info.dropdown-toggle{color:#fff;background-color:#117a8b;border-color:#10707f}.btn-info:not(:disabled):not(.disabled).active:focus,.btn-info:not(:disabled):not(.disabled):active:focus,.show>.btn-info.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(58,176,195,.5)}.btn-warning{color:#212529;background-color:#ffc107;border-color:#ffc107}.btn-warning:hover{color:#212529;background-color:#e0a800;border-color:#d39e00}.btn-warning.focus,.btn-warning:focus{color:#212529;background-color:#e0a800;border-color:#d39e00;box-shadow:0 0 0 .2rem rgba(222,170,12,.5)}.btn-warning.disabled,.btn-warning:disabled{color:#212529;background-color:#ffc107;border-color:#ffc107}.btn-warning:not(:disabled):not(.disabled).active,.btn-warning:not(:disabled):not(.disabled):active,.show>.btn-warning.dropdown-toggle{color:#212529;background-color:#d39e00;border-color:#c69500}.btn-warning:not(:disabled):not(.disabled).active:focus,.btn-warning:not(:disabled):not(.disabled):active:focus,.show>.btn-warning.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(222,170,12,.5)}.btn-danger{color:#fff;background-color:#dc3545;border-color:#dc3545}.btn-danger:hover{color:#fff;background-color:#c82333;border-color:#bd2130}.btn-danger.focus,.btn-danger:focus{color:#fff;background-color:#c82333;border-color:#bd2130;box-shadow:0 0 0 .2rem rgba(225,83,97,.5)}.btn-danger.disabled,.btn-danger:disabled{color:#fff;background-color:#dc3545;border-color:#dc3545}.btn-danger:not(:disabled):not(.disabled).active,.btn-danger:not(:disabled):not(.disabled):active,.show>.btn-danger.dropdown-toggle{color:#fff;background-color:#bd2130;border-color:#b21f2d}.btn-danger:not(:disabled):not(.disabled).active:focus,.btn-danger:not(:disabled):not(.disabled):active:focus,.show>.btn-danger.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(225,83,97,.5)}.btn-light{color:#212529;background-color:#f8f9fa;border-color:#f8f9fa}.btn-light:hover{color:#212529;background-color:#e2e6ea;border-color:#dae0e5}.btn-light.focus,.btn-light:focus{color:#212529;background-color:#e2e6ea;border-color:#dae0e5;box-shadow:0 0 0 .2rem rgba(216,217,219,.5)}.btn-light.disabled,.btn-light:disabled{color:#212529;background-color:#f8f9fa;border-color:#f8f9fa}.btn-light:not(:disabled):not(.disabled).active,.btn-light:not(:disabled):not(.disabled):active,.show>.btn-light.dropdown-toggle{color:#212529;background-color:#dae0e5;border-color:#d3d9df}.btn-light:not(:disabled):not(.disabled).active:focus,.btn-light:not(:disabled):not(.disabled):active:focus,.show>.btn-light.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(216,217,219,.5)}.btn-dark{color:#fff;background-color:#343a40;border-color:#343a40}.btn-dark:hover{color:#fff;background-color:#23272b;border-color:#1d2124}.btn-dark.focus,.btn-dark:focus{color:#fff;background-color:#23272b;border-color:#1d2124;box-shadow:0 0 0 .2rem rgba(82,88,93,.5)}.btn-dark.disabled,.btn-dark:disabled{color:#fff;background-color:#343a40;border-color:#343a40}.btn-dark:not(:disabled):not(.disabled).active,.btn-dark:not(:disabled):not(.disabled):active,.show>.btn-dark.dropdown-toggle{color:#fff;background-color:#1d2124;border-color:#171a1d}.btn-dark:not(:disabled):not(.disabled).active:focus,.btn-dark:not(:disabled):not(.disabled):active:focus,.show>.btn-dark.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(82,88,93,.5)}.btn-outline-primary{color:#007bff;border-color:#007bff}.btn-outline-primary:hover{color:#fff;background-color:#007bff;border-color:#007bff}.btn-outline-primary.focus,.btn-outline-primary:focus{box-shadow:0 0 0 .2rem rgba(0,123,255,.5)}.btn-outline-primary.disabled,.btn-outline-primary:disabled{color:#007bff;background-color:transparent}.btn-outline-primary:not(:disabled):not(.disabled).active,.btn-outline-primary:not(:disabled):not(.disabled):active,.show>.btn-outline-primary.dropdown-toggle{color:#fff;background-color:#007bff;border-color:#007bff}.btn-outline-primary:not(:disabled):not(.disabled).active:focus,.btn-outline-primary:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-primary.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(0,123,255,.5)}.btn-outline-secondary{color:#6c757d;border-color:#6c757d}.btn-outline-secondary:hover{color:#fff;background-color:#6c757d;border-color:#6c757d}.btn-outline-secondary.focus,.btn-outline-secondary:focus{box-shadow:0 0 0 .2rem rgba(108,117,125,.5)}.btn-outline-secondary.disabled,.btn-outline-secondary:disabled{color:#6c757d;background-color:transparent}.btn-outline-secondary:not(:disabled):not(.disabled).active,.btn-outline-secondary:not(:disabled):not(.disabled):active,.show>.btn-outline-secondary.dropdown-toggle{color:#fff;background-color:#6c757d;border-color:#6c757d}.btn-outline-secondary:not(:disabled):not(.disabled).active:focus,.btn-outline-secondary:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-secondary.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(108,117,125,.5)}.btn-outline-success{color:#28a745;border-color:#28a745}.btn-outline-success:hover{color:#fff;background-color:#28a745;border-color:#28a745}.btn-outline-success.focus,.btn-outline-success:focus{box-shadow:0 0 0 .2rem rgba(40,167,69,.5)}.btn-outline-success.disabled,.btn-outline-success:disabled{color:#28a745;background-color:transparent}.btn-outline-success:not(:disabled):not(.disabled).active,.btn-outline-success:not(:disabled):not(.disabled):active,.show>.btn-outline-success.dropdown-toggle{color:#fff;background-color:#28a745;border-color:#28a745}.btn-outline-success:not(:disabled):not(.disabled).active:focus,.btn-outline-success:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-success.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(40,167,69,.5)}.btn-outline-info{color:#17a2b8;border-color:#17a2b8}.btn-outline-info:hover{color:#fff;background-color:#17a2b8;border-color:#17a2b8}.btn-outline-info.focus,.btn-outline-info:focus{box-shadow:0 0 0 .2rem rgba(23,162,184,.5)}.btn-outline-info.disabled,.btn-outline-info:disabled{color:#17a2b8;background-color:transparent}.btn-outline-info:not(:disabled):not(.disabled).active,.btn-outline-info:not(:disabled):not(.disabled):active,.show>.btn-outline-info.dropdown-toggle{color:#fff;background-color:#17a2b8;border-color:#17a2b8}.btn-outline-info:not(:disabled):not(.disabled).active:focus,.btn-outline-info:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-info.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(23,162,184,.5)}.btn-outline-warning{color:#ffc107;border-color:#ffc107}.btn-outline-warning:hover{color:#212529;background-color:#ffc107;border-color:#ffc107}.btn-outline-warning.focus,.btn-outline-warning:focus{box-shadow:0 0 0 .2rem rgba(255,193,7,.5)}.btn-outline-warning.disabled,.btn-outline-warning:disabled{color:#ffc107;background-color:transparent}.btn-outline-warning:not(:disabled):not(.disabled).active,.btn-outline-warning:not(:disabled):not(.disabled):active,.show>.btn-outline-warning.dropdown-toggle{color:#212529;background-color:#ffc107;border-color:#ffc107}.btn-outline-warning:not(:disabled):not(.disabled).active:focus,.btn-outline-warning:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-warning.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(255,193,7,.5)}.btn-outline-danger{color:#dc3545;border-color:#dc3545}.btn-outline-danger:hover{color:#fff;background-color:#dc3545;border-color:#dc3545}.btn-outline-danger.focus,.btn-outline-danger:focus{box-shadow:0 0 0 .2rem rgba(220,53,69,.5)}.btn-outline-danger.disabled,.btn-outline-danger:disabled{color:#dc3545;background-color:transparent}.btn-outline-danger:not(:disabled):not(.disabled).active,.btn-outline-danger:not(:disabled):not(.disabled):active,.show>.btn-outline-danger.dropdown-toggle{color:#fff;background-color:#dc3545;border-color:#dc3545}.btn-outline-danger:not(:disabled):not(.disabled).active:focus,.btn-outline-danger:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-danger.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(220,53,69,.5)}.btn-outline-light{color:#f8f9fa;border-color:#f8f9fa}.btn-outline-light:hover{color:#212529;background-color:#f8f9fa;border-color:#f8f9fa}.btn-outline-light.focus,.btn-outline-light:focus{box-shadow:0 0 0 .2rem rgba(248,249,250,.5)}.btn-outline-light.disabled,.btn-outline-light:disabled{color:#f8f9fa;background-color:transparent}.btn-outline-light:not(:disabled):not(.disabled).active,.btn-outline-light:not(:disabled):not(.disabled):active,.show>.btn-outline-light.dropdown-toggle{color:#212529;background-color:#f8f9fa;border-color:#f8f9fa}.btn-outline-light:not(:disabled):not(.disabled).active:focus,.btn-outline-light:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-light.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(248,249,250,.5)}.btn-outline-dark{color:#343a40;border-color:#343a40}.btn-outline-dark:hover{color:#fff;background-color:#343a40;border-color:#343a40}.btn-outline-dark.focus,.btn-outline-dark:focus{box-shadow:0 0 0 .2rem rgba(52,58,64,.5)}.btn-outline-dark.disabled,.btn-outline-dark:disabled{color:#343a40;background-color:transparent}.btn-outline-dark:not(:disabled):not(.disabled).active,.btn-outline-dark:not(:disabled):not(.disabled):active,.show>.btn-outline-dark.dropdown-toggle{color:#fff;background-color:#343a40;border-color:#343a40}.btn-outline-dark:not(:disabled):not(.disabled).active:focus,.btn-outline-dark:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-dark.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(52,58,64,.5)}.btn-link{font-weight:400;color:#007bff;text-decoration:none}.btn-link:hover{color:#0056b3;text-decoration:underline}.btn-link.focus,.btn-link:focus{text-decoration:underline}.btn-link.disabled,.btn-link:disabled{color:#6c757d;pointer-events:none}.btn-group-lg>.btn,.btn-lg{padding:.5rem 1rem;font-size:1.25rem;line-height:1.5;border-radius:.3rem}.btn-group-sm>.btn,.btn-sm{padding:.25rem .5rem;font-size:.875rem;line-height:1.5;border-radius:.2rem}.btn-block{display:block;width:100%}.btn-block+.btn-block{margin-top:.5rem}input[type=button].btn-block,input[type=reset].btn-block,input[type=submit].btn-block{width:100%}.fade{transition:opacity .15s linear}@media (prefers-reduced-motion:reduce){.fade{transition:none}}.fade:not(.show){opacity:0}.collapse:not(.show){display:none}.collapsing{position:relative;height:0;overflow:hidden;transition:height .35s ease}@media (prefers-reduced-motion:reduce){.collapsing{transition:none}}.dropdown,.dropleft,.dropright,.dropup{position:relative}.dropdown-toggle{white-space:nowrap}.dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid;border-right:.3em solid transparent;border-bottom:0;border-left:.3em solid transparent}.dropdown-toggle:empty::after{margin-left:0}.dropdown-menu{position:absolute;top:100%;left:0;z-index:1000;display:none;float:left;min-width:10rem;padding:.5rem 0;margin:.125rem 0 0;font-size:1rem;color:#212529;text-align:left;list-style:none;background-color:#fff;background-clip:padding-box;border:1px solid rgba(0,0,0,.15);border-radius:.25rem}.dropdown-menu-left{right:auto;left:0}.dropdown-menu-right{right:0;left:auto}@media (min-width:576px){.dropdown-menu-sm-left{right:auto;left:0}.dropdown-menu-sm-right{right:0;left:auto}}@media (min-width:768px){.dropdown-menu-md-left{right:auto;left:0}.dropdown-menu-md-right{right:0;left:auto}}@media (min-width:992px){.dropdown-menu-lg-left{right:auto;left:0}.dropdown-menu-lg-right{right:0;left:auto}}@media (min-width:1200px){.dropdown-menu-xl-left{right:auto;left:0}.dropdown-menu-xl-right{right:0;left:auto}}.dropup .dropdown-menu{top:auto;bottom:100%;margin-top:0;margin-bottom:.125rem}.dropup .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:0;border-right:.3em solid transparent;border-bottom:.3em solid;border-left:.3em solid transparent}.dropup .dropdown-toggle:empty::after{margin-left:0}.dropright .dropdown-menu{top:0;right:auto;left:100%;margin-top:0;margin-left:.125rem}.dropright .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid transparent;border-right:0;border-bottom:.3em solid transparent;border-left:.3em solid}.dropright .dropdown-toggle:empty::after{margin-left:0}.dropright .dropdown-toggle::after{vertical-align:0}.dropleft .dropdown-menu{top:0;right:100%;left:auto;margin-top:0;margin-right:.125rem}.dropleft .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:""}.dropleft .dropdown-toggle::after{display:none}.dropleft .dropdown-toggle::before{display:inline-block;margin-right:.255em;vertical-align:.255em;content:"";border-top:.3em solid transparent;border-right:.3em solid;border-bottom:.3em solid transparent}.dropleft .dropdown-toggle:empty::after{margin-left:0}.dropleft .dropdown-toggle::before{vertical-align:0}.dropdown-menu[x-placement^=bottom],.dropdown-menu[x-placement^=left],.dropdown-menu[x-placement^=right],.dropdown-menu[x-placement^=top]{right:auto;bottom:auto}.dropdown-divider{height:0;margin:.5rem 0;overflow:hidden;border-top:1px solid #e9ecef}.dropdown-item{display:block;width:100%;padding:.25rem 1.5rem;clear:both;font-weight:400;color:#212529;text-align:inherit;white-space:nowrap;background-color:transparent;border:0}.dropdown-item:focus,.dropdown-item:hover{color:#16181b;text-decoration:none;background-color:#f8f9fa}.dropdown-item.active,.dropdown-item:active{color:#fff;text-decoration:none;background-color:#007bff}.dropdown-item.disabled,.dropdown-item:disabled{color:#6c757d;pointer-events:none;background-color:transparent}.dropdown-menu.show{display:block}.dropdown-header{display:block;padding:.5rem 1.5rem;margin-bottom:0;font-size:.875rem;color:#6c757d;white-space:nowrap}.dropdown-item-text{display:block;padding:.25rem 1.5rem;color:#212529}.btn-group,.btn-group-vertical{position:relative;display:-ms-inline-flexbox;display:inline-flex;vertical-align:middle}.btn-group-vertical>.btn,.btn-group>.btn{position:relative;-ms-flex:1 1 auto;flex:1 1 auto}.btn-group-vertical>.btn:hover,.btn-group>.btn:hover{z-index:1}.btn-group-vertical>.btn.active,.btn-group-vertical>.btn:active,.btn-group-vertical>.btn:focus,.btn-group>.btn.active,.btn-group>.btn:active,.btn-group>.btn:focus{z-index:1}.btn-toolbar{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;-ms-flex-pack:start;justify-content:flex-start}.btn-toolbar .input-group{width:auto}.btn-group>.btn-group:not(:first-child),.btn-group>.btn:not(:first-child){margin-left:-1px}.btn-group>.btn-group:not(:last-child)>.btn,.btn-group>.btn:not(:last-child):not(.dropdown-toggle){border-top-right-radius:0;border-bottom-right-radius:0}.btn-group>.btn-group:not(:first-child)>.btn,.btn-group>.btn:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.dropdown-toggle-split{padding-right:.5625rem;padding-left:.5625rem}.dropdown-toggle-split::after,.dropright .dropdown-toggle-split::after,.dropup .dropdown-toggle-split::after{margin-left:0}.dropleft .dropdown-toggle-split::before{margin-right:0}.btn-group-sm>.btn+.dropdown-toggle-split,.btn-sm+.dropdown-toggle-split{padding-right:.375rem;padding-left:.375rem}.btn-group-lg>.btn+.dropdown-toggle-split,.btn-lg+.dropdown-toggle-split{padding-right:.75rem;padding-left:.75rem}.btn-group-vertical{-ms-flex-direction:column;flex-direction:column;-ms-flex-align:start;align-items:flex-start;-ms-flex-pack:center;justify-content:center}.btn-group-vertical>.btn,.btn-group-vertical>.btn-group{width:100%}.btn-group-vertical>.btn-group:not(:first-child),.btn-group-vertical>.btn:not(:first-child){margin-top:-1px}.btn-group-vertical>.btn-group:not(:last-child)>.btn,.btn-group-vertical>.btn:not(:last-child):not(.dropdown-toggle){border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn-group:not(:first-child)>.btn,.btn-group-vertical>.btn:not(:first-child){border-top-left-radius:0;border-top-right-radius:0}.btn-group-toggle>.btn,.btn-group-toggle>.btn-group>.btn{margin-bottom:0}.btn-group-toggle>.btn input[type=checkbox],.btn-group-toggle>.btn input[type=radio],.btn-group-toggle>.btn-group>.btn input[type=checkbox],.btn-group-toggle>.btn-group>.btn input[type=radio]{position:absolute;clip:rect(0,0,0,0);pointer-events:none}.input-group{position:relative;display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;-ms-flex-align:stretch;align-items:stretch;width:100%}.input-group>.custom-file,.input-group>.custom-select,.input-group>.form-control,.input-group>.form-control-plaintext{position:relative;-ms-flex:1 1 auto;flex:1 1 auto;width:1%;min-width:0;margin-bottom:0}.input-group>.custom-file+.custom-file,.input-group>.custom-file+.custom-select,.input-group>.custom-file+.form-control,.input-group>.custom-select+.custom-file,.input-group>.custom-select+.custom-select,.input-group>.custom-select+.form-control,.input-group>.form-control+.custom-file,.input-group>.form-control+.custom-select,.input-group>.form-control+.form-control,.input-group>.form-control-plaintext+.custom-file,.input-group>.form-control-plaintext+.custom-select,.input-group>.form-control-plaintext+.form-control{margin-left:-1px}.input-group>.custom-file .custom-file-input:focus~.custom-file-label,.input-group>.custom-select:focus,.input-group>.form-control:focus{z-index:3}.input-group>.custom-file .custom-file-input:focus{z-index:4}.input-group>.custom-select:not(:last-child),.input-group>.form-control:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}.input-group>.custom-select:not(:first-child),.input-group>.form-control:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.input-group>.custom-file{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center}.input-group>.custom-file:not(:last-child) .custom-file-label,.input-group>.custom-file:not(:last-child) .custom-file-label::after{border-top-right-radius:0;border-bottom-right-radius:0}.input-group>.custom-file:not(:first-child) .custom-file-label{border-top-left-radius:0;border-bottom-left-radius:0}.input-group-append,.input-group-prepend{display:-ms-flexbox;display:flex}.input-group-append .btn,.input-group-prepend .btn{position:relative;z-index:2}.input-group-append .btn:focus,.input-group-prepend .btn:focus{z-index:3}.input-group-append .btn+.btn,.input-group-append .btn+.input-group-text,.input-group-append .input-group-text+.btn,.input-group-append .input-group-text+.input-group-text,.input-group-prepend .btn+.btn,.input-group-prepend .btn+.input-group-text,.input-group-prepend .input-group-text+.btn,.input-group-prepend .input-group-text+.input-group-text{margin-left:-1px}.input-group-prepend{margin-right:-1px}.input-group-append{margin-left:-1px}.input-group-text{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;padding:.375rem .75rem;margin-bottom:0;font-size:1rem;font-weight:400;line-height:1.5;color:#495057;text-align:center;white-space:nowrap;background-color:#e9ecef;border:1px solid #ced4da;border-radius:.25rem}.input-group-text input[type=checkbox],.input-group-text input[type=radio]{margin-top:0}.input-group-lg>.custom-select,.input-group-lg>.form-control:not(textarea){height:calc(1.5em + 1rem + 2px)}.input-group-lg>.custom-select,.input-group-lg>.form-control,.input-group-lg>.input-group-append>.btn,.input-group-lg>.input-group-append>.input-group-text,.input-group-lg>.input-group-prepend>.btn,.input-group-lg>.input-group-prepend>.input-group-text{padding:.5rem 1rem;font-size:1.25rem;line-height:1.5;border-radius:.3rem}.input-group-sm>.custom-select,.input-group-sm>.form-control:not(textarea){height:calc(1.5em + .5rem + 2px)}.input-group-sm>.custom-select,.input-group-sm>.form-control,.input-group-sm>.input-group-append>.btn,.input-group-sm>.input-group-append>.input-group-text,.input-group-sm>.input-group-prepend>.btn,.input-group-sm>.input-group-prepend>.input-group-text{padding:.25rem .5rem;font-size:.875rem;line-height:1.5;border-radius:.2rem}.input-group-lg>.custom-select,.input-group-sm>.custom-select{padding-right:1.75rem}.input-group>.input-group-append:last-child>.btn:not(:last-child):not(.dropdown-toggle),.input-group>.input-group-append:last-child>.input-group-text:not(:last-child),.input-group>.input-group-append:not(:last-child)>.btn,.input-group>.input-group-append:not(:last-child)>.input-group-text,.input-group>.input-group-prepend>.btn,.input-group>.input-group-prepend>.input-group-text{border-top-right-radius:0;border-bottom-right-radius:0}.input-group>.input-group-append>.btn,.input-group>.input-group-append>.input-group-text,.input-group>.input-group-prepend:first-child>.btn:not(:first-child),.input-group>.input-group-prepend:first-child>.input-group-text:not(:first-child),.input-group>.input-group-prepend:not(:first-child)>.btn,.input-group>.input-group-prepend:not(:first-child)>.input-group-text{border-top-left-radius:0;border-bottom-left-radius:0}.custom-control{position:relative;z-index:1;display:block;min-height:1.5rem;padding-left:1.5rem}.custom-control-inline{display:-ms-inline-flexbox;display:inline-flex;margin-right:1rem}.custom-control-input{position:absolute;left:0;z-index:-1;width:1rem;height:1.25rem;opacity:0}.custom-control-input:checked~.custom-control-label::before{color:#fff;border-color:#007bff;background-color:#007bff}.custom-control-input:focus~.custom-control-label::before{box-shadow:0 0 0 .2rem rgba(0,123,255,.25)}.custom-control-input:focus:not(:checked)~.custom-control-label::before{border-color:#80bdff}.custom-control-input:not(:disabled):active~.custom-control-label::before{color:#fff;background-color:#b3d7ff;border-color:#b3d7ff}.custom-control-input:disabled~.custom-control-label,.custom-control-input[disabled]~.custom-control-label{color:#6c757d}.custom-control-input:disabled~.custom-control-label::before,.custom-control-input[disabled]~.custom-control-label::before{background-color:#e9ecef}.custom-control-label{position:relative;margin-bottom:0;vertical-align:top}.custom-control-label::before{position:absolute;top:.25rem;left:-1.5rem;display:block;width:1rem;height:1rem;pointer-events:none;content:"";background-color:#fff;border:#adb5bd solid 1px}.custom-control-label::after{position:absolute;top:.25rem;left:-1.5rem;display:block;width:1rem;height:1rem;content:"";background:no-repeat 50%/50% 50%}.custom-checkbox .custom-control-label::before{border-radius:.25rem}.custom-checkbox .custom-control-input:checked~.custom-control-label::after{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 8 8'%3e%3cpath fill='%23fff' d='M6.564.75l-3.59 3.612-1.538-1.55L0 4.26l2.974 2.99L8 2.193z'/%3e%3c/svg%3e")}.custom-checkbox .custom-control-input:indeterminate~.custom-control-label::before{border-color:#007bff;background-color:#007bff}.custom-checkbox .custom-control-input:indeterminate~.custom-control-label::after{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='4' height='4' viewBox='0 0 4 4'%3e%3cpath stroke='%23fff' d='M0 2h4'/%3e%3c/svg%3e")}.custom-checkbox .custom-control-input:disabled:checked~.custom-control-label::before{background-color:rgba(0,123,255,.5)}.custom-checkbox .custom-control-input:disabled:indeterminate~.custom-control-label::before{background-color:rgba(0,123,255,.5)}.custom-radio .custom-control-label::before{border-radius:50%}.custom-radio .custom-control-input:checked~.custom-control-label::after{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23fff'/%3e%3c/svg%3e")}.custom-radio .custom-control-input:disabled:checked~.custom-control-label::before{background-color:rgba(0,123,255,.5)}.custom-switch{padding-left:2.25rem}.custom-switch .custom-control-label::before{left:-2.25rem;width:1.75rem;pointer-events:all;border-radius:.5rem}.custom-switch .custom-control-label::after{top:calc(.25rem + 2px);left:calc(-2.25rem + 2px);width:calc(1rem - 4px);height:calc(1rem - 4px);background-color:#adb5bd;border-radius:.5rem;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out,-webkit-transform .15s ease-in-out;transition:transform .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:transform .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out,-webkit-transform .15s ease-in-out}@media (prefers-reduced-motion:reduce){.custom-switch .custom-control-label::after{transition:none}}.custom-switch .custom-control-input:checked~.custom-control-label::after{background-color:#fff;-webkit-transform:translateX(.75rem);transform:translateX(.75rem)}.custom-switch .custom-control-input:disabled:checked~.custom-control-label::before{background-color:rgba(0,123,255,.5)}.custom-select{display:inline-block;width:100%;height:calc(1.5em + .75rem + 2px);padding:.375rem 1.75rem .375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:#495057;vertical-align:middle;background:#fff url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='4' height='5' viewBox='0 0 4 5'%3e%3cpath fill='%23343a40' d='M2 0L0 2h4zm0 5L0 3h4z'/%3e%3c/svg%3e") no-repeat right .75rem center/8px 10px;border:1px solid #ced4da;border-radius:.25rem;-webkit-appearance:none;-moz-appearance:none;appearance:none}.custom-select:focus{border-color:#80bdff;outline:0;box-shadow:0 0 0 .2rem rgba(0,123,255,.25)}.custom-select:focus::-ms-value{color:#495057;background-color:#fff}.custom-select[multiple],.custom-select[size]:not([size="1"]){height:auto;padding-right:.75rem;background-image:none}.custom-select:disabled{color:#6c757d;background-color:#e9ecef}.custom-select::-ms-expand{display:none}.custom-select:-moz-focusring{color:transparent;text-shadow:0 0 0 #495057}.custom-select-sm{height:calc(1.5em + .5rem + 2px);padding-top:.25rem;padding-bottom:.25rem;padding-left:.5rem;font-size:.875rem}.custom-select-lg{height:calc(1.5em + 1rem + 2px);padding-top:.5rem;padding-bottom:.5rem;padding-left:1rem;font-size:1.25rem}.custom-file{position:relative;display:inline-block;width:100%;height:calc(1.5em + .75rem + 2px);margin-bottom:0}.custom-file-input{position:relative;z-index:2;width:100%;height:calc(1.5em + .75rem + 2px);margin:0;opacity:0}.custom-file-input:focus~.custom-file-label{border-color:#80bdff;box-shadow:0 0 0 .2rem rgba(0,123,255,.25)}.custom-file-input:disabled~.custom-file-label,.custom-file-input[disabled]~.custom-file-label{background-color:#e9ecef}.custom-file-input:lang(en)~.custom-file-label::after{content:"Browse"}.custom-file-input~.custom-file-label[data-browse]::after{content:attr(data-browse)}.custom-file-label{position:absolute;top:0;right:0;left:0;z-index:1;height:calc(1.5em + .75rem + 2px);padding:.375rem .75rem;font-weight:400;line-height:1.5;color:#495057;background-color:#fff;border:1px solid #ced4da;border-radius:.25rem}.custom-file-label::after{position:absolute;top:0;right:0;bottom:0;z-index:3;display:block;height:calc(1.5em + .75rem);padding:.375rem .75rem;line-height:1.5;color:#495057;content:"Browse";background-color:#e9ecef;border-left:inherit;border-radius:0 .25rem .25rem 0}.custom-range{width:100%;height:1.4rem;padding:0;background-color:transparent;-webkit-appearance:none;-moz-appearance:none;appearance:none}.custom-range:focus{outline:0}.custom-range:focus::-webkit-slider-thumb{box-shadow:0 0 0 1px #fff,0 0 0 .2rem rgba(0,123,255,.25)}.custom-range:focus::-moz-range-thumb{box-shadow:0 0 0 1px #fff,0 0 0 .2rem rgba(0,123,255,.25)}.custom-range:focus::-ms-thumb{box-shadow:0 0 0 1px #fff,0 0 0 .2rem rgba(0,123,255,.25)}.custom-range::-moz-focus-outer{border:0}.custom-range::-webkit-slider-thumb{width:1rem;height:1rem;margin-top:-.25rem;background-color:#007bff;border:0;border-radius:1rem;-webkit-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;-webkit-appearance:none;appearance:none}@media (prefers-reduced-motion:reduce){.custom-range::-webkit-slider-thumb{-webkit-transition:none;transition:none}}.custom-range::-webkit-slider-thumb:active{background-color:#b3d7ff}.custom-range::-webkit-slider-runnable-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:#dee2e6;border-color:transparent;border-radius:1rem}.custom-range::-moz-range-thumb{width:1rem;height:1rem;background-color:#007bff;border:0;border-radius:1rem;-moz-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;-moz-appearance:none;appearance:none}@media (prefers-reduced-motion:reduce){.custom-range::-moz-range-thumb{-moz-transition:none;transition:none}}.custom-range::-moz-range-thumb:active{background-color:#b3d7ff}.custom-range::-moz-range-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:#dee2e6;border-color:transparent;border-radius:1rem}.custom-range::-ms-thumb{width:1rem;height:1rem;margin-top:0;margin-right:.2rem;margin-left:.2rem;background-color:#007bff;border:0;border-radius:1rem;-ms-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;appearance:none}@media (prefers-reduced-motion:reduce){.custom-range::-ms-thumb{-ms-transition:none;transition:none}}.custom-range::-ms-thumb:active{background-color:#b3d7ff}.custom-range::-ms-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:transparent;border-color:transparent;border-width:.5rem}.custom-range::-ms-fill-lower{background-color:#dee2e6;border-radius:1rem}.custom-range::-ms-fill-upper{margin-right:15px;background-color:#dee2e6;border-radius:1rem}.custom-range:disabled::-webkit-slider-thumb{background-color:#adb5bd}.custom-range:disabled::-webkit-slider-runnable-track{cursor:default}.custom-range:disabled::-moz-range-thumb{background-color:#adb5bd}.custom-range:disabled::-moz-range-track{cursor:default}.custom-range:disabled::-ms-thumb{background-color:#adb5bd}.custom-control-label::before,.custom-file-label,.custom-select{transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.custom-control-label::before,.custom-file-label,.custom-select{transition:none}}.nav{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;padding-left:0;margin-bottom:0;list-style:none}.nav-link{display:block;padding:.5rem 1rem}.nav-link:focus,.nav-link:hover{text-decoration:none}.nav-link.disabled{color:#6c757d;pointer-events:none;cursor:default}.nav-tabs{border-bottom:1px solid #dee2e6}.nav-tabs .nav-item{margin-bottom:-1px}.nav-tabs .nav-link{border:1px solid transparent;border-top-left-radius:.25rem;border-top-right-radius:.25rem}.nav-tabs .nav-link:focus,.nav-tabs .nav-link:hover{border-color:#e9ecef #e9ecef #dee2e6}.nav-tabs .nav-link.disabled{color:#6c757d;background-color:transparent;border-color:transparent}.nav-tabs .nav-item.show .nav-link,.nav-tabs .nav-link.active{color:#495057;background-color:#fff;border-color:#dee2e6 #dee2e6 #fff}.nav-tabs .dropdown-menu{margin-top:-1px;border-top-left-radius:0;border-top-right-radius:0}.nav-pills .nav-link{border-radius:.25rem}.nav-pills .nav-link.active,.nav-pills .show>.nav-link{color:#fff;background-color:#007bff}.nav-fill .nav-item,.nav-fill>.nav-link{-ms-flex:1 1 auto;flex:1 1 auto;text-align:center}.nav-justified .nav-item,.nav-justified>.nav-link{-ms-flex-preferred-size:0;flex-basis:0;-ms-flex-positive:1;flex-grow:1;text-align:center}.tab-content>.tab-pane{display:none}.tab-content>.active{display:block}.navbar{position:relative;display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;-ms-flex-align:center;align-items:center;-ms-flex-pack:justify;justify-content:space-between;padding:.5rem 1rem}.navbar .container,.navbar .container-fluid,.navbar .container-lg,.navbar .container-md,.navbar .container-sm,.navbar .container-xl{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;-ms-flex-align:center;align-items:center;-ms-flex-pack:justify;justify-content:space-between}.navbar-brand{display:inline-block;padding-top:.3125rem;padding-bottom:.3125rem;margin-right:1rem;font-size:1.25rem;line-height:inherit;white-space:nowrap}.navbar-brand:focus,.navbar-brand:hover{text-decoration:none}.navbar-nav{display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;padding-left:0;margin-bottom:0;list-style:none}.navbar-nav .nav-link{padding-right:0;padding-left:0}.navbar-nav .dropdown-menu{position:static;float:none}.navbar-text{display:inline-block;padding-top:.5rem;padding-bottom:.5rem}.navbar-collapse{-ms-flex-preferred-size:100%;flex-basis:100%;-ms-flex-positive:1;flex-grow:1;-ms-flex-align:center;align-items:center}.navbar-toggler{padding:.25rem .75rem;font-size:1.25rem;line-height:1;background-color:transparent;border:1px solid transparent;border-radius:.25rem}.navbar-toggler:focus,.navbar-toggler:hover{text-decoration:none}.navbar-toggler-icon{display:inline-block;width:1.5em;height:1.5em;vertical-align:middle;content:"";background:no-repeat center center;background-size:100% 100%}@media (max-width:575.98px){.navbar-expand-sm>.container,.navbar-expand-sm>.container-fluid,.navbar-expand-sm>.container-lg,.navbar-expand-sm>.container-md,.navbar-expand-sm>.container-sm,.navbar-expand-sm>.container-xl{padding-right:0;padding-left:0}}@media (min-width:576px){.navbar-expand-sm{-ms-flex-flow:row nowrap;flex-flow:row nowrap;-ms-flex-pack:start;justify-content:flex-start}.navbar-expand-sm .navbar-nav{-ms-flex-direction:row;flex-direction:row}.navbar-expand-sm .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-sm .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-sm>.container,.navbar-expand-sm>.container-fluid,.navbar-expand-sm>.container-lg,.navbar-expand-sm>.container-md,.navbar-expand-sm>.container-sm,.navbar-expand-sm>.container-xl{-ms-flex-wrap:nowrap;flex-wrap:nowrap}.navbar-expand-sm .navbar-collapse{display:-ms-flexbox!important;display:flex!important;-ms-flex-preferred-size:auto;flex-basis:auto}.navbar-expand-sm .navbar-toggler{display:none}}@media (max-width:767.98px){.navbar-expand-md>.container,.navbar-expand-md>.container-fluid,.navbar-expand-md>.container-lg,.navbar-expand-md>.container-md,.navbar-expand-md>.container-sm,.navbar-expand-md>.container-xl{padding-right:0;padding-left:0}}@media (min-width:768px){.navbar-expand-md{-ms-flex-flow:row nowrap;flex-flow:row nowrap;-ms-flex-pack:start;justify-content:flex-start}.navbar-expand-md .navbar-nav{-ms-flex-direction:row;flex-direction:row}.navbar-expand-md .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-md .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-md>.container,.navbar-expand-md>.container-fluid,.navbar-expand-md>.container-lg,.navbar-expand-md>.container-md,.navbar-expand-md>.container-sm,.navbar-expand-md>.container-xl{-ms-flex-wrap:nowrap;flex-wrap:nowrap}.navbar-expand-md .navbar-collapse{display:-ms-flexbox!important;display:flex!important;-ms-flex-preferred-size:auto;flex-basis:auto}.navbar-expand-md .navbar-toggler{display:none}}@media (max-width:991.98px){.navbar-expand-lg>.container,.navbar-expand-lg>.container-fluid,.navbar-expand-lg>.container-lg,.navbar-expand-lg>.container-md,.navbar-expand-lg>.container-sm,.navbar-expand-lg>.container-xl{padding-right:0;padding-left:0}}@media (min-width:992px){.navbar-expand-lg{-ms-flex-flow:row nowrap;flex-flow:row nowrap;-ms-flex-pack:start;justify-content:flex-start}.navbar-expand-lg .navbar-nav{-ms-flex-direction:row;flex-direction:row}.navbar-expand-lg .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-lg .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-lg>.container,.navbar-expand-lg>.container-fluid,.navbar-expand-lg>.container-lg,.navbar-expand-lg>.container-md,.navbar-expand-lg>.container-sm,.navbar-expand-lg>.container-xl{-ms-flex-wrap:nowrap;flex-wrap:nowrap}.navbar-expand-lg .navbar-collapse{display:-ms-flexbox!important;display:flex!important;-ms-flex-preferred-size:auto;flex-basis:auto}.navbar-expand-lg .navbar-toggler{display:none}}@media (max-width:1199.98px){.navbar-expand-xl>.container,.navbar-expand-xl>.container-fluid,.navbar-expand-xl>.container-lg,.navbar-expand-xl>.container-md,.navbar-expand-xl>.container-sm,.navbar-expand-xl>.container-xl{padding-right:0;padding-left:0}}@media (min-width:1200px){.navbar-expand-xl{-ms-flex-flow:row nowrap;flex-flow:row nowrap;-ms-flex-pack:start;justify-content:flex-start}.navbar-expand-xl .navbar-nav{-ms-flex-direction:row;flex-direction:row}.navbar-expand-xl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xl .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-xl>.container,.navbar-expand-xl>.container-fluid,.navbar-expand-xl>.container-lg,.navbar-expand-xl>.container-md,.navbar-expand-xl>.container-sm,.navbar-expand-xl>.container-xl{-ms-flex-wrap:nowrap;flex-wrap:nowrap}.navbar-expand-xl .navbar-collapse{display:-ms-flexbox!important;display:flex!important;-ms-flex-preferred-size:auto;flex-basis:auto}.navbar-expand-xl .navbar-toggler{display:none}}.navbar-expand{-ms-flex-flow:row nowrap;flex-flow:row nowrap;-ms-flex-pack:start;justify-content:flex-start}.navbar-expand>.container,.navbar-expand>.container-fluid,.navbar-expand>.container-lg,.navbar-expand>.container-md,.navbar-expand>.container-sm,.navbar-expand>.container-xl{padding-right:0;padding-left:0}.navbar-expand .navbar-nav{-ms-flex-direction:row;flex-direction:row}.navbar-expand .navbar-nav .dropdown-menu{position:absolute}.navbar-expand .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand>.container,.navbar-expand>.container-fluid,.navbar-expand>.container-lg,.navbar-expand>.container-md,.navbar-expand>.container-sm,.navbar-expand>.container-xl{-ms-flex-wrap:nowrap;flex-wrap:nowrap}.navbar-expand .navbar-collapse{display:-ms-flexbox!important;display:flex!important;-ms-flex-preferred-size:auto;flex-basis:auto}.navbar-expand .navbar-toggler{display:none}.navbar-light .navbar-brand{color:rgba(0,0,0,.9)}.navbar-light .navbar-brand:focus,.navbar-light .navbar-brand:hover{color:rgba(0,0,0,.9)}.navbar-light .navbar-nav .nav-link{color:rgba(0,0,0,.5)}.navbar-light .navbar-nav .nav-link:focus,.navbar-light .navbar-nav .nav-link:hover{color:rgba(0,0,0,.7)}.navbar-light .navbar-nav .nav-link.disabled{color:rgba(0,0,0,.3)}.navbar-light .navbar-nav .active>.nav-link,.navbar-light .navbar-nav .nav-link.active,.navbar-light .navbar-nav .nav-link.show,.navbar-light .navbar-nav .show>.nav-link{color:rgba(0,0,0,.9)}.navbar-light .navbar-toggler{color:rgba(0,0,0,.5);border-color:rgba(0,0,0,.1)}.navbar-light .navbar-toggler-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='30' height='30' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%280, 0, 0, 0.5%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}.navbar-light .navbar-text{color:rgba(0,0,0,.5)}.navbar-light .navbar-text a{color:rgba(0,0,0,.9)}.navbar-light .navbar-text a:focus,.navbar-light .navbar-text a:hover{color:rgba(0,0,0,.9)}.navbar-dark .navbar-brand{color:#fff}.navbar-dark .navbar-brand:focus,.navbar-dark .navbar-brand:hover{color:#fff}.navbar-dark .navbar-nav .nav-link{color:rgba(255,255,255,.5)}.navbar-dark .navbar-nav .nav-link:focus,.navbar-dark .navbar-nav .nav-link:hover{color:rgba(255,255,255,.75)}.navbar-dark .navbar-nav .nav-link.disabled{color:rgba(255,255,255,.25)}.navbar-dark .navbar-nav .active>.nav-link,.navbar-dark .navbar-nav .nav-link.active,.navbar-dark .navbar-nav .nav-link.show,.navbar-dark .navbar-nav .show>.nav-link{color:#fff}.navbar-dark .navbar-toggler{color:rgba(255,255,255,.5);border-color:rgba(255,255,255,.1)}.navbar-dark .navbar-toggler-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='30' height='30' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.5%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}.navbar-dark .navbar-text{color:rgba(255,255,255,.5)}.navbar-dark .navbar-text a{color:#fff}.navbar-dark .navbar-text a:focus,.navbar-dark .navbar-text a:hover{color:#fff}.card{position:relative;display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;min-width:0;word-wrap:break-word;background-color:#fff;background-clip:border-box;border:1px solid rgba(0,0,0,.125);border-radius:.25rem}.card>hr{margin-right:0;margin-left:0}.card>.list-group{border-top:inherit;border-bottom:inherit}.card>.list-group:first-child{border-top-width:0;border-top-left-radius:calc(.25rem - 1px);border-top-right-radius:calc(.25rem - 1px)}.card>.list-group:last-child{border-bottom-width:0;border-bottom-right-radius:calc(.25rem - 1px);border-bottom-left-radius:calc(.25rem - 1px)}.card>.card-header+.list-group,.card>.list-group+.card-footer{border-top:0}.card-body{-ms-flex:1 1 auto;flex:1 1 auto;min-height:1px;padding:1.25rem}.card-title{margin-bottom:.75rem}.card-subtitle{margin-top:-.375rem;margin-bottom:0}.card-text:last-child{margin-bottom:0}.card-link:hover{text-decoration:none}.card-link+.card-link{margin-left:1.25rem}.card-header{padding:.75rem 1.25rem;margin-bottom:0;background-color:rgba(0,0,0,.03);border-bottom:1px solid rgba(0,0,0,.125)}.card-header:first-child{border-radius:calc(.25rem - 1px) calc(.25rem - 1px) 0 0}.card-footer{padding:.75rem 1.25rem;background-color:rgba(0,0,0,.03);border-top:1px solid rgba(0,0,0,.125)}.card-footer:last-child{border-radius:0 0 calc(.25rem - 1px) calc(.25rem - 1px)}.card-header-tabs{margin-right:-.625rem;margin-bottom:-.75rem;margin-left:-.625rem;border-bottom:0}.card-header-pills{margin-right:-.625rem;margin-left:-.625rem}.card-img-overlay{position:absolute;top:0;right:0;bottom:0;left:0;padding:1.25rem;border-radius:calc(.25rem - 1px)}.card-img,.card-img-bottom,.card-img-top{-ms-flex-negative:0;flex-shrink:0;width:100%}.card-img,.card-img-top{border-top-left-radius:calc(.25rem - 1px);border-top-right-radius:calc(.25rem - 1px)}.card-img,.card-img-bottom{border-bottom-right-radius:calc(.25rem - 1px);border-bottom-left-radius:calc(.25rem - 1px)}.card-deck .card{margin-bottom:15px}@media (min-width:576px){.card-deck{display:-ms-flexbox;display:flex;-ms-flex-flow:row wrap;flex-flow:row wrap;margin-right:-15px;margin-left:-15px}.card-deck .card{-ms-flex:1 0 0%;flex:1 0 0%;margin-right:15px;margin-bottom:0;margin-left:15px}}.card-group>.card{margin-bottom:15px}@media (min-width:576px){.card-group{display:-ms-flexbox;display:flex;-ms-flex-flow:row wrap;flex-flow:row wrap}.card-group>.card{-ms-flex:1 0 0%;flex:1 0 0%;margin-bottom:0}.card-group>.card+.card{margin-left:0;border-left:0}.card-group>.card:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}.card-group>.card:not(:last-child) .card-header,.card-group>.card:not(:last-child) .card-img-top{border-top-right-radius:0}.card-group>.card:not(:last-child) .card-footer,.card-group>.card:not(:last-child) .card-img-bottom{border-bottom-right-radius:0}.card-group>.card:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.card-group>.card:not(:first-child) .card-header,.card-group>.card:not(:first-child) .card-img-top{border-top-left-radius:0}.card-group>.card:not(:first-child) .card-footer,.card-group>.card:not(:first-child) .card-img-bottom{border-bottom-left-radius:0}}.card-columns .card{margin-bottom:.75rem}@media (min-width:576px){.card-columns{-webkit-column-count:3;-moz-column-count:3;column-count:3;-webkit-column-gap:1.25rem;-moz-column-gap:1.25rem;column-gap:1.25rem;orphans:1;widows:1}.card-columns .card{display:inline-block;width:100%}}.accordion{overflow-anchor:none}.accordion>.card{overflow:hidden}.accordion>.card:not(:last-of-type){border-bottom:0;border-bottom-right-radius:0;border-bottom-left-radius:0}.accordion>.card:not(:first-of-type){border-top-left-radius:0;border-top-right-radius:0}.accordion>.card>.card-header{border-radius:0;margin-bottom:-1px}.breadcrumb{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;padding:.75rem 1rem;margin-bottom:1rem;list-style:none;background-color:#e9ecef;border-radius:.25rem}.breadcrumb-item{display:-ms-flexbox;display:flex}.breadcrumb-item+.breadcrumb-item{padding-left:.5rem}.breadcrumb-item+.breadcrumb-item::before{display:inline-block;padding-right:.5rem;color:#6c757d;content:"/"}.breadcrumb-item+.breadcrumb-item:hover::before{text-decoration:underline}.breadcrumb-item+.breadcrumb-item:hover::before{text-decoration:none}.breadcrumb-item.active{color:#6c757d}.pagination{display:-ms-flexbox;display:flex;padding-left:0;list-style:none;border-radius:.25rem}.page-link{position:relative;display:block;padding:.5rem .75rem;margin-left:-1px;line-height:1.25;color:#007bff;background-color:#fff;border:1px solid #dee2e6}.page-link:hover{z-index:2;color:#0056b3;text-decoration:none;background-color:#e9ecef;border-color:#dee2e6}.page-link:focus{z-index:3;outline:0;box-shadow:0 0 0 .2rem rgba(0,123,255,.25)}.page-item:first-child .page-link{margin-left:0;border-top-left-radius:.25rem;border-bottom-left-radius:.25rem}.page-item:last-child .page-link{border-top-right-radius:.25rem;border-bottom-right-radius:.25rem}.page-item.active .page-link{z-index:3;color:#fff;background-color:#007bff;border-color:#007bff}.page-item.disabled .page-link{color:#6c757d;pointer-events:none;cursor:auto;background-color:#fff;border-color:#dee2e6}.pagination-lg .page-link{padding:.75rem 1.5rem;font-size:1.25rem;line-height:1.5}.pagination-lg .page-item:first-child .page-link{border-top-left-radius:.3rem;border-bottom-left-radius:.3rem}.pagination-lg .page-item:last-child .page-link{border-top-right-radius:.3rem;border-bottom-right-radius:.3rem}.pagination-sm .page-link{padding:.25rem .5rem;font-size:.875rem;line-height:1.5}.pagination-sm .page-item:first-child .page-link{border-top-left-radius:.2rem;border-bottom-left-radius:.2rem}.pagination-sm .page-item:last-child .page-link{border-top-right-radius:.2rem;border-bottom-right-radius:.2rem}.badge{display:inline-block;padding:.25em .4em;font-size:75%;font-weight:700;line-height:1;text-align:center;white-space:nowrap;vertical-align:baseline;border-radius:.25rem;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.badge{transition:none}}a.badge:focus,a.badge:hover{text-decoration:none}.badge:empty{display:none}.btn .badge{position:relative;top:-1px}.badge-pill{padding-right:.6em;padding-left:.6em;border-radius:10rem}.badge-primary{color:#fff;background-color:#007bff}a.badge-primary:focus,a.badge-primary:hover{color:#fff;background-color:#0062cc}a.badge-primary.focus,a.badge-primary:focus{outline:0;box-shadow:0 0 0 .2rem rgba(0,123,255,.5)}.badge-secondary{color:#fff;background-color:#6c757d}a.badge-secondary:focus,a.badge-secondary:hover{color:#fff;background-color:#545b62}a.badge-secondary.focus,a.badge-secondary:focus{outline:0;box-shadow:0 0 0 .2rem rgba(108,117,125,.5)}.badge-success{color:#fff;background-color:#28a745}a.badge-success:focus,a.badge-success:hover{color:#fff;background-color:#1e7e34}a.badge-success.focus,a.badge-success:focus{outline:0;box-shadow:0 0 0 .2rem rgba(40,167,69,.5)}.badge-info{color:#fff;background-color:#17a2b8}a.badge-info:focus,a.badge-info:hover{color:#fff;background-color:#117a8b}a.badge-info.focus,a.badge-info:focus{outline:0;box-shadow:0 0 0 .2rem rgba(23,162,184,.5)}.badge-warning{color:#212529;background-color:#ffc107}a.badge-warning:focus,a.badge-warning:hover{color:#212529;background-color:#d39e00}a.badge-warning.focus,a.badge-warning:focus{outline:0;box-shadow:0 0 0 .2rem rgba(255,193,7,.5)}.badge-danger{color:#fff;background-color:#dc3545}a.badge-danger:focus,a.badge-danger:hover{color:#fff;background-color:#bd2130}a.badge-danger.focus,a.badge-danger:focus{outline:0;box-shadow:0 0 0 .2rem rgba(220,53,69,.5)}.badge-light{color:#212529;background-color:#f8f9fa}a.badge-light:focus,a.badge-light:hover{color:#212529;background-color:#dae0e5}a.badge-light.focus,a.badge-light:focus{outline:0;box-shadow:0 0 0 .2rem rgba(248,249,250,.5)}.badge-dark{color:#fff;background-color:#343a40}a.badge-dark:focus,a.badge-dark:hover{color:#fff;background-color:#1d2124}a.badge-dark.focus,a.badge-dark:focus{outline:0;box-shadow:0 0 0 .2rem rgba(52,58,64,.5)}.jumbotron{padding:2rem 1rem;margin-bottom:2rem;background-color:#e9ecef;border-radius:.3rem}@media (min-width:576px){.jumbotron{padding:4rem 2rem}}.jumbotron-fluid{padding-right:0;padding-left:0;border-radius:0}.alert{position:relative;padding:.75rem 1.25rem;margin-bottom:1rem;border:1px solid transparent;border-radius:.25rem}.alert-heading{color:inherit}.alert-link{font-weight:700}.alert-dismissible{padding-right:4rem}.alert-dismissible .close{position:absolute;top:0;right:0;padding:.75rem 1.25rem;color:inherit}.alert-primary{color:#004085;background-color:#cce5ff;border-color:#b8daff}.alert-primary hr{border-top-color:#9fcdff}.alert-primary .alert-link{color:#002752}.alert-secondary{color:#383d41;background-color:#e2e3e5;border-color:#d6d8db}.alert-secondary hr{border-top-color:#c8cbcf}.alert-secondary .alert-link{color:#202326}.alert-success{color:#155724;background-color:#d4edda;border-color:#c3e6cb}.alert-success hr{border-top-color:#b1dfbb}.alert-success .alert-link{color:#0b2e13}.alert-info{color:#0c5460;background-color:#d1ecf1;border-color:#bee5eb}.alert-info hr{border-top-color:#abdde5}.alert-info .alert-link{color:#062c33}.alert-warning{color:#856404;background-color:#fff3cd;border-color:#ffeeba}.alert-warning hr{border-top-color:#ffe8a1}.alert-warning .alert-link{color:#533f03}.alert-danger{color:#721c24;background-color:#f8d7da;border-color:#f5c6cb}.alert-danger hr{border-top-color:#f1b0b7}.alert-danger .alert-link{color:#491217}.alert-light{color:#818182;background-color:#fefefe;border-color:#fdfdfe}.alert-light hr{border-top-color:#ececf6}.alert-light .alert-link{color:#686868}.alert-dark{color:#1b1e21;background-color:#d6d8d9;border-color:#c6c8ca}.alert-dark hr{border-top-color:#b9bbbe}.alert-dark .alert-link{color:#040505}@-webkit-keyframes progress-bar-stripes{from{background-position:1rem 0}to{background-position:0 0}}@keyframes progress-bar-stripes{from{background-position:1rem 0}to{background-position:0 0}}.progress{display:-ms-flexbox;display:flex;height:1rem;overflow:hidden;line-height:0;font-size:.75rem;background-color:#e9ecef;border-radius:.25rem}.progress-bar{display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;-ms-flex-pack:center;justify-content:center;overflow:hidden;color:#fff;text-align:center;white-space:nowrap;background-color:#007bff;transition:width .6s ease}@media (prefers-reduced-motion:reduce){.progress-bar{transition:none}}.progress-bar-striped{background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-size:1rem 1rem}.progress-bar-animated{-webkit-animation:progress-bar-stripes 1s linear infinite;animation:progress-bar-stripes 1s linear infinite}@media (prefers-reduced-motion:reduce){.progress-bar-animated{-webkit-animation:none;animation:none}}.media{display:-ms-flexbox;display:flex;-ms-flex-align:start;align-items:flex-start}.media-body{-ms-flex:1;flex:1}.list-group{display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;padding-left:0;margin-bottom:0;border-radius:.25rem}.list-group-item-action{width:100%;color:#495057;text-align:inherit}.list-group-item-action:focus,.list-group-item-action:hover{z-index:1;color:#495057;text-decoration:none;background-color:#f8f9fa}.list-group-item-action:active{color:#212529;background-color:#e9ecef}.list-group-item{position:relative;display:block;padding:.75rem 1.25rem;background-color:#fff;border:1px solid rgba(0,0,0,.125)}.list-group-item:first-child{border-top-left-radius:inherit;border-top-right-radius:inherit}.list-group-item:last-child{border-bottom-right-radius:inherit;border-bottom-left-radius:inherit}.list-group-item.disabled,.list-group-item:disabled{color:#6c757d;pointer-events:none;background-color:#fff}.list-group-item.active{z-index:2;color:#fff;background-color:#007bff;border-color:#007bff}.list-group-item+.list-group-item{border-top-width:0}.list-group-item+.list-group-item.active{margin-top:-1px;border-top-width:1px}.list-group-horizontal{-ms-flex-direction:row;flex-direction:row}.list-group-horizontal>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal>.list-group-item.active{margin-top:0}.list-group-horizontal>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}@media (min-width:576px){.list-group-horizontal-sm{-ms-flex-direction:row;flex-direction:row}.list-group-horizontal-sm>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-sm>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal-sm>.list-group-item.active{margin-top:0}.list-group-horizontal-sm>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal-sm>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}@media (min-width:768px){.list-group-horizontal-md{-ms-flex-direction:row;flex-direction:row}.list-group-horizontal-md>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-md>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal-md>.list-group-item.active{margin-top:0}.list-group-horizontal-md>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal-md>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}@media (min-width:992px){.list-group-horizontal-lg{-ms-flex-direction:row;flex-direction:row}.list-group-horizontal-lg>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-lg>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal-lg>.list-group-item.active{margin-top:0}.list-group-horizontal-lg>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal-lg>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}@media (min-width:1200px){.list-group-horizontal-xl{-ms-flex-direction:row;flex-direction:row}.list-group-horizontal-xl>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-xl>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal-xl>.list-group-item.active{margin-top:0}.list-group-horizontal-xl>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal-xl>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}.list-group-flush{border-radius:0}.list-group-flush>.list-group-item{border-width:0 0 1px}.list-group-flush>.list-group-item:last-child{border-bottom-width:0}.list-group-item-primary{color:#004085;background-color:#b8daff}.list-group-item-primary.list-group-item-action:focus,.list-group-item-primary.list-group-item-action:hover{color:#004085;background-color:#9fcdff}.list-group-item-primary.list-group-item-action.active{color:#fff;background-color:#004085;border-color:#004085}.list-group-item-secondary{color:#383d41;background-color:#d6d8db}.list-group-item-secondary.list-group-item-action:focus,.list-group-item-secondary.list-group-item-action:hover{color:#383d41;background-color:#c8cbcf}.list-group-item-secondary.list-group-item-action.active{color:#fff;background-color:#383d41;border-color:#383d41}.list-group-item-success{color:#155724;background-color:#c3e6cb}.list-group-item-success.list-group-item-action:focus,.list-group-item-success.list-group-item-action:hover{color:#155724;background-color:#b1dfbb}.list-group-item-success.list-group-item-action.active{color:#fff;background-color:#155724;border-color:#155724}.list-group-item-info{color:#0c5460;background-color:#bee5eb}.list-group-item-info.list-group-item-action:focus,.list-group-item-info.list-group-item-action:hover{color:#0c5460;background-color:#abdde5}.list-group-item-info.list-group-item-action.active{color:#fff;background-color:#0c5460;border-color:#0c5460}.list-group-item-warning{color:#856404;background-color:#ffeeba}.list-group-item-warning.list-group-item-action:focus,.list-group-item-warning.list-group-item-action:hover{color:#856404;background-color:#ffe8a1}.list-group-item-warning.list-group-item-action.active{color:#fff;background-color:#856404;border-color:#856404}.list-group-item-danger{color:#721c24;background-color:#f5c6cb}.list-group-item-danger.list-group-item-action:focus,.list-group-item-danger.list-group-item-action:hover{color:#721c24;background-color:#f1b0b7}.list-group-item-danger.list-group-item-action.active{color:#fff;background-color:#721c24;border-color:#721c24}.list-group-item-light{color:#818182;background-color:#fdfdfe}.list-group-item-light.list-group-item-action:focus,.list-group-item-light.list-group-item-action:hover{color:#818182;background-color:#ececf6}.list-group-item-light.list-group-item-action.active{color:#fff;background-color:#818182;border-color:#818182}.list-group-item-dark{color:#1b1e21;background-color:#c6c8ca}.list-group-item-dark.list-group-item-action:focus,.list-group-item-dark.list-group-item-action:hover{color:#1b1e21;background-color:#b9bbbe}.list-group-item-dark.list-group-item-action.active{color:#fff;background-color:#1b1e21;border-color:#1b1e21}.close{float:right;font-size:1.5rem;font-weight:700;line-height:1;color:#000;text-shadow:0 1px 0 #fff;opacity:.5}.close:hover{color:#000;text-decoration:none}.close:not(:disabled):not(.disabled):focus,.close:not(:disabled):not(.disabled):hover{opacity:.75}button.close{padding:0;background-color:transparent;border:0}a.close.disabled{pointer-events:none}.toast{-ms-flex-preferred-size:350px;flex-basis:350px;max-width:350px;font-size:.875rem;background-color:rgba(255,255,255,.85);background-clip:padding-box;border:1px solid rgba(0,0,0,.1);box-shadow:0 .25rem .75rem rgba(0,0,0,.1);opacity:0;border-radius:.25rem}.toast:not(:last-child){margin-bottom:.75rem}.toast.showing{opacity:1}.toast.show{display:block;opacity:1}.toast.hide{display:none}.toast-header{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;padding:.25rem .75rem;color:#6c757d;background-color:rgba(255,255,255,.85);background-clip:padding-box;border-bottom:1px solid rgba(0,0,0,.05);border-top-left-radius:calc(.25rem - 1px);border-top-right-radius:calc(.25rem - 1px)}.toast-body{padding:.75rem}.modal-open{overflow:hidden}.modal-open .modal{overflow-x:hidden;overflow-y:auto}.modal{position:fixed;top:0;left:0;z-index:1050;display:none;width:100%;height:100%;overflow:hidden;outline:0}.modal-dialog{position:relative;width:auto;margin:.5rem;pointer-events:none}.modal.fade .modal-dialog{transition:-webkit-transform .3s ease-out;transition:transform .3s ease-out;transition:transform .3s ease-out,-webkit-transform .3s ease-out;-webkit-transform:translate(0,-50px);transform:translate(0,-50px)}@media (prefers-reduced-motion:reduce){.modal.fade .modal-dialog{transition:none}}.modal.show .modal-dialog{-webkit-transform:none;transform:none}.modal.modal-static .modal-dialog{-webkit-transform:scale(1.02);transform:scale(1.02)}.modal-dialog-scrollable{display:-ms-flexbox;display:flex;max-height:calc(100% - 1rem)}.modal-dialog-scrollable .modal-content{max-height:calc(100vh - 1rem);overflow:hidden}.modal-dialog-scrollable .modal-footer,.modal-dialog-scrollable .modal-header{-ms-flex-negative:0;flex-shrink:0}.modal-dialog-scrollable .modal-body{overflow-y:auto}.modal-dialog-centered{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;min-height:calc(100% - 1rem)}.modal-dialog-centered::before{display:block;height:calc(100vh - 1rem);height:-webkit-min-content;height:-moz-min-content;height:min-content;content:""}.modal-dialog-centered.modal-dialog-scrollable{-ms-flex-direction:column;flex-direction:column;-ms-flex-pack:center;justify-content:center;height:100%}.modal-dialog-centered.modal-dialog-scrollable .modal-content{max-height:none}.modal-dialog-centered.modal-dialog-scrollable::before{content:none}.modal-content{position:relative;display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;width:100%;pointer-events:auto;background-color:#fff;background-clip:padding-box;border:1px solid rgba(0,0,0,.2);border-radius:.3rem;outline:0}.modal-backdrop{position:fixed;top:0;left:0;z-index:1040;width:100vw;height:100vh;background-color:#000}.modal-backdrop.fade{opacity:0}.modal-backdrop.show{opacity:.5}.modal-header{display:-ms-flexbox;display:flex;-ms-flex-align:start;align-items:flex-start;-ms-flex-pack:justify;justify-content:space-between;padding:1rem 1rem;border-bottom:1px solid #dee2e6;border-top-left-radius:calc(.3rem - 1px);border-top-right-radius:calc(.3rem - 1px)}.modal-header .close{padding:1rem 1rem;margin:-1rem -1rem -1rem auto}.modal-title{margin-bottom:0;line-height:1.5}.modal-body{position:relative;-ms-flex:1 1 auto;flex:1 1 auto;padding:1rem}.modal-footer{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;-ms-flex-align:center;align-items:center;-ms-flex-pack:end;justify-content:flex-end;padding:.75rem;border-top:1px solid #dee2e6;border-bottom-right-radius:calc(.3rem - 1px);border-bottom-left-radius:calc(.3rem - 1px)}.modal-footer>*{margin:.25rem}.modal-scrollbar-measure{position:absolute;top:-9999px;width:50px;height:50px;overflow:scroll}@media (min-width:576px){.modal-dialog{max-width:500px;margin:1.75rem auto}.modal-dialog-scrollable{max-height:calc(100% - 3.5rem)}.modal-dialog-scrollable .modal-content{max-height:calc(100vh - 3.5rem)}.modal-dialog-centered{min-height:calc(100% - 3.5rem)}.modal-dialog-centered::before{height:calc(100vh - 3.5rem);height:-webkit-min-content;height:-moz-min-content;height:min-content}.modal-sm{max-width:300px}}@media (min-width:992px){.modal-lg,.modal-xl{max-width:800px}}@media (min-width:1200px){.modal-xl{max-width:1140px}}.tooltip{position:absolute;z-index:1070;display:block;margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;word-spacing:normal;white-space:normal;line-break:auto;font-size:.875rem;word-wrap:break-word;opacity:0}.tooltip.show{opacity:.9}.tooltip .arrow{position:absolute;display:block;width:.8rem;height:.4rem}.tooltip .arrow::before{position:absolute;content:"";border-color:transparent;border-style:solid}.bs-tooltip-auto[x-placement^=top],.bs-tooltip-top{padding:.4rem 0}.bs-tooltip-auto[x-placement^=top] .arrow,.bs-tooltip-top .arrow{bottom:0}.bs-tooltip-auto[x-placement^=top] .arrow::before,.bs-tooltip-top .arrow::before{top:0;border-width:.4rem .4rem 0;border-top-color:#000}.bs-tooltip-auto[x-placement^=right],.bs-tooltip-right{padding:0 .4rem}.bs-tooltip-auto[x-placement^=right] .arrow,.bs-tooltip-right .arrow{left:0;width:.4rem;height:.8rem}.bs-tooltip-auto[x-placement^=right] .arrow::before,.bs-tooltip-right .arrow::before{right:0;border-width:.4rem .4rem .4rem 0;border-right-color:#000}.bs-tooltip-auto[x-placement^=bottom],.bs-tooltip-bottom{padding:.4rem 0}.bs-tooltip-auto[x-placement^=bottom] .arrow,.bs-tooltip-bottom .arrow{top:0}.bs-tooltip-auto[x-placement^=bottom] .arrow::before,.bs-tooltip-bottom .arrow::before{bottom:0;border-width:0 .4rem .4rem;border-bottom-color:#000}.bs-tooltip-auto[x-placement^=left],.bs-tooltip-left{padding:0 .4rem}.bs-tooltip-auto[x-placement^=left] .arrow,.bs-tooltip-left .arrow{right:0;width:.4rem;height:.8rem}.bs-tooltip-auto[x-placement^=left] .arrow::before,.bs-tooltip-left .arrow::before{left:0;border-width:.4rem 0 .4rem .4rem;border-left-color:#000}.tooltip-inner{max-width:200px;padding:.25rem .5rem;color:#fff;text-align:center;background-color:#000;border-radius:.25rem}.popover{position:absolute;top:0;left:0;z-index:1060;display:block;max-width:276px;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;word-spacing:normal;white-space:normal;line-break:auto;font-size:.875rem;word-wrap:break-word;background-color:#fff;background-clip:padding-box;border:1px solid rgba(0,0,0,.2);border-radius:.3rem}.popover .arrow{position:absolute;display:block;width:1rem;height:.5rem;margin:0 .3rem}.popover .arrow::after,.popover .arrow::before{position:absolute;display:block;content:"";border-color:transparent;border-style:solid}.bs-popover-auto[x-placement^=top],.bs-popover-top{margin-bottom:.5rem}.bs-popover-auto[x-placement^=top]>.arrow,.bs-popover-top>.arrow{bottom:calc(-.5rem - 1px)}.bs-popover-auto[x-placement^=top]>.arrow::before,.bs-popover-top>.arrow::before{bottom:0;border-width:.5rem .5rem 0;border-top-color:rgba(0,0,0,.25)}.bs-popover-auto[x-placement^=top]>.arrow::after,.bs-popover-top>.arrow::after{bottom:1px;border-width:.5rem .5rem 0;border-top-color:#fff}.bs-popover-auto[x-placement^=right],.bs-popover-right{margin-left:.5rem}.bs-popover-auto[x-placement^=right]>.arrow,.bs-popover-right>.arrow{left:calc(-.5rem - 1px);width:.5rem;height:1rem;margin:.3rem 0}.bs-popover-auto[x-placement^=right]>.arrow::before,.bs-popover-right>.arrow::before{left:0;border-width:.5rem .5rem .5rem 0;border-right-color:rgba(0,0,0,.25)}.bs-popover-auto[x-placement^=right]>.arrow::after,.bs-popover-right>.arrow::after{left:1px;border-width:.5rem .5rem .5rem 0;border-right-color:#fff}.bs-popover-auto[x-placement^=bottom],.bs-popover-bottom{margin-top:.5rem}.bs-popover-auto[x-placement^=bottom]>.arrow,.bs-popover-bottom>.arrow{top:calc(-.5rem - 1px)}.bs-popover-auto[x-placement^=bottom]>.arrow::before,.bs-popover-bottom>.arrow::before{top:0;border-width:0 .5rem .5rem .5rem;border-bottom-color:rgba(0,0,0,.25)}.bs-popover-auto[x-placement^=bottom]>.arrow::after,.bs-popover-bottom>.arrow::after{top:1px;border-width:0 .5rem .5rem .5rem;border-bottom-color:#fff}.bs-popover-auto[x-placement^=bottom] .popover-header::before,.bs-popover-bottom .popover-header::before{position:absolute;top:0;left:50%;display:block;width:1rem;margin-left:-.5rem;content:"";border-bottom:1px solid #f7f7f7}.bs-popover-auto[x-placement^=left],.bs-popover-left{margin-right:.5rem}.bs-popover-auto[x-placement^=left]>.arrow,.bs-popover-left>.arrow{right:calc(-.5rem - 1px);width:.5rem;height:1rem;margin:.3rem 0}.bs-popover-auto[x-placement^=left]>.arrow::before,.bs-popover-left>.arrow::before{right:0;border-width:.5rem 0 .5rem .5rem;border-left-color:rgba(0,0,0,.25)}.bs-popover-auto[x-placement^=left]>.arrow::after,.bs-popover-left>.arrow::after{right:1px;border-width:.5rem 0 .5rem .5rem;border-left-color:#fff}.popover-header{padding:.5rem .75rem;margin-bottom:0;font-size:1rem;background-color:#f7f7f7;border-bottom:1px solid #ebebeb;border-top-left-radius:calc(.3rem - 1px);border-top-right-radius:calc(.3rem - 1px)}.popover-header:empty{display:none}.popover-body{padding:.5rem .75rem;color:#212529}.carousel{position:relative}.carousel.pointer-event{-ms-touch-action:pan-y;touch-action:pan-y}.carousel-inner{position:relative;width:100%;overflow:hidden}.carousel-inner::after{display:block;clear:both;content:""}.carousel-item{position:relative;display:none;float:left;width:100%;margin-right:-100%;-webkit-backface-visibility:hidden;backface-visibility:hidden;transition:-webkit-transform .6s ease-in-out;transition:transform .6s ease-in-out;transition:transform .6s ease-in-out,-webkit-transform .6s ease-in-out}@media (prefers-reduced-motion:reduce){.carousel-item{transition:none}}.carousel-item-next,.carousel-item-prev,.carousel-item.active{display:block}.active.carousel-item-right,.carousel-item-next:not(.carousel-item-left){-webkit-transform:translateX(100%);transform:translateX(100%)}.active.carousel-item-left,.carousel-item-prev:not(.carousel-item-right){-webkit-transform:translateX(-100%);transform:translateX(-100%)}.carousel-fade .carousel-item{opacity:0;transition-property:opacity;-webkit-transform:none;transform:none}.carousel-fade .carousel-item-next.carousel-item-left,.carousel-fade .carousel-item-prev.carousel-item-right,.carousel-fade .carousel-item.active{z-index:1;opacity:1}.carousel-fade .active.carousel-item-left,.carousel-fade .active.carousel-item-right{z-index:0;opacity:0;transition:opacity 0s .6s}@media (prefers-reduced-motion:reduce){.carousel-fade .active.carousel-item-left,.carousel-fade .active.carousel-item-right{transition:none}}.carousel-control-next,.carousel-control-prev{position:absolute;top:0;bottom:0;z-index:1;display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;-ms-flex-pack:center;justify-content:center;width:15%;color:#fff;text-align:center;opacity:.5;transition:opacity .15s ease}@media (prefers-reduced-motion:reduce){.carousel-control-next,.carousel-control-prev{transition:none}}.carousel-control-next:focus,.carousel-control-next:hover,.carousel-control-prev:focus,.carousel-control-prev:hover{color:#fff;text-decoration:none;outline:0;opacity:.9}.carousel-control-prev{left:0}.carousel-control-next{right:0}.carousel-control-next-icon,.carousel-control-prev-icon{display:inline-block;width:20px;height:20px;background:no-repeat 50%/100% 100%}.carousel-control-prev-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' width='8' height='8' viewBox='0 0 8 8'%3e%3cpath d='M5.25 0l-4 4 4 4 1.5-1.5L4.25 4l2.5-2.5L5.25 0z'/%3e%3c/svg%3e")}.carousel-control-next-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' width='8' height='8' viewBox='0 0 8 8'%3e%3cpath d='M2.75 0l-1.5 1.5L3.75 4l-2.5 2.5L2.75 8l4-4-4-4z'/%3e%3c/svg%3e")}.carousel-indicators{position:absolute;right:0;bottom:0;left:0;z-index:15;display:-ms-flexbox;display:flex;-ms-flex-pack:center;justify-content:center;padding-left:0;margin-right:15%;margin-left:15%;list-style:none}.carousel-indicators li{box-sizing:content-box;-ms-flex:0 1 auto;flex:0 1 auto;width:30px;height:3px;margin-right:3px;margin-left:3px;text-indent:-999px;cursor:pointer;background-color:#fff;background-clip:padding-box;border-top:10px solid transparent;border-bottom:10px solid transparent;opacity:.5;transition:opacity .6s ease}@media (prefers-reduced-motion:reduce){.carousel-indicators li{transition:none}}.carousel-indicators .active{opacity:1}.carousel-caption{position:absolute;right:15%;bottom:20px;left:15%;z-index:10;padding-top:20px;padding-bottom:20px;color:#fff;text-align:center}@-webkit-keyframes spinner-border{to{-webkit-transform:rotate(360deg);transform:rotate(360deg)}}@keyframes spinner-border{to{-webkit-transform:rotate(360deg);transform:rotate(360deg)}}.spinner-border{display:inline-block;width:2rem;height:2rem;vertical-align:text-bottom;border:.25em solid currentColor;border-right-color:transparent;border-radius:50%;-webkit-animation:spinner-border .75s linear infinite;animation:spinner-border .75s linear infinite}.spinner-border-sm{width:1rem;height:1rem;border-width:.2em}@-webkit-keyframes spinner-grow{0%{-webkit-transform:scale(0);transform:scale(0)}50%{opacity:1;-webkit-transform:none;transform:none}}@keyframes spinner-grow{0%{-webkit-transform:scale(0);transform:scale(0)}50%{opacity:1;-webkit-transform:none;transform:none}}.spinner-grow{display:inline-block;width:2rem;height:2rem;vertical-align:text-bottom;background-color:currentColor;border-radius:50%;opacity:0;-webkit-animation:spinner-grow .75s linear infinite;animation:spinner-grow .75s linear infinite}.spinner-grow-sm{width:1rem;height:1rem}.align-baseline{vertical-align:baseline!important}.align-top{vertical-align:top!important}.align-middle{vertical-align:middle!important}.align-bottom{vertical-align:bottom!important}.align-text-bottom{vertical-align:text-bottom!important}.align-text-top{vertical-align:text-top!important}.bg-primary{background-color:#007bff!important}a.bg-primary:focus,a.bg-primary:hover,button.bg-primary:focus,button.bg-primary:hover{background-color:#0062cc!important}.bg-secondary{background-color:#6c757d!important}a.bg-secondary:focus,a.bg-secondary:hover,button.bg-secondary:focus,button.bg-secondary:hover{background-color:#545b62!important}.bg-success{background-color:#28a745!important}a.bg-success:focus,a.bg-success:hover,button.bg-success:focus,button.bg-success:hover{background-color:#1e7e34!important}.bg-info{background-color:#17a2b8!important}a.bg-info:focus,a.bg-info:hover,button.bg-info:focus,button.bg-info:hover{background-color:#117a8b!important}.bg-warning{background-color:#ffc107!important}a.bg-warning:focus,a.bg-warning:hover,button.bg-warning:focus,button.bg-warning:hover{background-color:#d39e00!important}.bg-danger{background-color:#dc3545!important}a.bg-danger:focus,a.bg-danger:hover,button.bg-danger:focus,button.bg-danger:hover{background-color:#bd2130!important}.bg-light{background-color:#f8f9fa!important}a.bg-light:focus,a.bg-light:hover,button.bg-light:focus,button.bg-light:hover{background-color:#dae0e5!important}.bg-dark{background-color:#343a40!important}a.bg-dark:focus,a.bg-dark:hover,button.bg-dark:focus,button.bg-dark:hover{background-color:#1d2124!important}.bg-white{background-color:#fff!important}.bg-transparent{background-color:transparent!important}.border{border:1px solid #dee2e6!important}.border-top{border-top:1px solid #dee2e6!important}.border-right{border-right:1px solid #dee2e6!important}.border-bottom{border-bottom:1px solid #dee2e6!important}.border-left{border-left:1px solid #dee2e6!important}.border-0{border:0!important}.border-top-0{border-top:0!important}.border-right-0{border-right:0!important}.border-bottom-0{border-bottom:0!important}.border-left-0{border-left:0!important}.border-primary{border-color:#007bff!important}.border-secondary{border-color:#6c757d!important}.border-success{border-color:#28a745!important}.border-info{border-color:#17a2b8!important}.border-warning{border-color:#ffc107!important}.border-danger{border-color:#dc3545!important}.border-light{border-color:#f8f9fa!important}.border-dark{border-color:#343a40!important}.border-white{border-color:#fff!important}.rounded-sm{border-radius:.2rem!important}.rounded{border-radius:.25rem!important}.rounded-top{border-top-left-radius:.25rem!important;border-top-right-radius:.25rem!important}.rounded-right{border-top-right-radius:.25rem!important;border-bottom-right-radius:.25rem!important}.rounded-bottom{border-bottom-right-radius:.25rem!important;border-bottom-left-radius:.25rem!important}.rounded-left{border-top-left-radius:.25rem!important;border-bottom-left-radius:.25rem!important}.rounded-lg{border-radius:.3rem!important}.rounded-circle{border-radius:50%!important}.rounded-pill{border-radius:50rem!important}.rounded-0{border-radius:0!important}.clearfix::after{display:block;clear:both;content:""}.d-none{display:none!important}.d-inline{display:inline!important}.d-inline-block{display:inline-block!important}.d-block{display:block!important}.d-table{display:table!important}.d-table-row{display:table-row!important}.d-table-cell{display:table-cell!important}.d-flex{display:-ms-flexbox!important;display:flex!important}.d-inline-flex{display:-ms-inline-flexbox!important;display:inline-flex!important}@media (min-width:576px){.d-sm-none{display:none!important}.d-sm-inline{display:inline!important}.d-sm-inline-block{display:inline-block!important}.d-sm-block{display:block!important}.d-sm-table{display:table!important}.d-sm-table-row{display:table-row!important}.d-sm-table-cell{display:table-cell!important}.d-sm-flex{display:-ms-flexbox!important;display:flex!important}.d-sm-inline-flex{display:-ms-inline-flexbox!important;display:inline-flex!important}}@media (min-width:768px){.d-md-none{display:none!important}.d-md-inline{display:inline!important}.d-md-inline-block{display:inline-block!important}.d-md-block{display:block!important}.d-md-table{display:table!important}.d-md-table-row{display:table-row!important}.d-md-table-cell{display:table-cell!important}.d-md-flex{display:-ms-flexbox!important;display:flex!important}.d-md-inline-flex{display:-ms-inline-flexbox!important;display:inline-flex!important}}@media (min-width:992px){.d-lg-none{display:none!important}.d-lg-inline{display:inline!important}.d-lg-inline-block{display:inline-block!important}.d-lg-block{display:block!important}.d-lg-table{display:table!important}.d-lg-table-row{display:table-row!important}.d-lg-table-cell{display:table-cell!important}.d-lg-flex{display:-ms-flexbox!important;display:flex!important}.d-lg-inline-flex{display:-ms-inline-flexbox!important;display:inline-flex!important}}@media (min-width:1200px){.d-xl-none{display:none!important}.d-xl-inline{display:inline!important}.d-xl-inline-block{display:inline-block!important}.d-xl-block{display:block!important}.d-xl-table{display:table!important}.d-xl-table-row{display:table-row!important}.d-xl-table-cell{display:table-cell!important}.d-xl-flex{display:-ms-flexbox!important;display:flex!important}.d-xl-inline-flex{display:-ms-inline-flexbox!important;display:inline-flex!important}}@media print{.d-print-none{display:none!important}.d-print-inline{display:inline!important}.d-print-inline-block{display:inline-block!important}.d-print-block{display:block!important}.d-print-table{display:table!important}.d-print-table-row{display:table-row!important}.d-print-table-cell{display:table-cell!important}.d-print-flex{display:-ms-flexbox!important;display:flex!important}.d-print-inline-flex{display:-ms-inline-flexbox!important;display:inline-flex!important}}.embed-responsive{position:relative;display:block;width:100%;padding:0;overflow:hidden}.embed-responsive::before{display:block;content:""}.embed-responsive .embed-responsive-item,.embed-responsive embed,.embed-responsive iframe,.embed-responsive object,.embed-responsive video{position:absolute;top:0;bottom:0;left:0;width:100%;height:100%;border:0}.embed-responsive-21by9::before{padding-top:42.857143%}.embed-responsive-16by9::before{padding-top:56.25%}.embed-responsive-4by3::before{padding-top:75%}.embed-responsive-1by1::before{padding-top:100%}.flex-row{-ms-flex-direction:row!important;flex-direction:row!important}.flex-column{-ms-flex-direction:column!important;flex-direction:column!important}.flex-row-reverse{-ms-flex-direction:row-reverse!important;flex-direction:row-reverse!important}.flex-column-reverse{-ms-flex-direction:column-reverse!important;flex-direction:column-reverse!important}.flex-wrap{-ms-flex-wrap:wrap!important;flex-wrap:wrap!important}.flex-nowrap{-ms-flex-wrap:nowrap!important;flex-wrap:nowrap!important}.flex-wrap-reverse{-ms-flex-wrap:wrap-reverse!important;flex-wrap:wrap-reverse!important}.flex-fill{-ms-flex:1 1 auto!important;flex:1 1 auto!important}.flex-grow-0{-ms-flex-positive:0!important;flex-grow:0!important}.flex-grow-1{-ms-flex-positive:1!important;flex-grow:1!important}.flex-shrink-0{-ms-flex-negative:0!important;flex-shrink:0!important}.flex-shrink-1{-ms-flex-negative:1!important;flex-shrink:1!important}.justify-content-start{-ms-flex-pack:start!important;justify-content:flex-start!important}.justify-content-end{-ms-flex-pack:end!important;justify-content:flex-end!important}.justify-content-center{-ms-flex-pack:center!important;justify-content:center!important}.justify-content-between{-ms-flex-pack:justify!important;justify-content:space-between!important}.justify-content-around{-ms-flex-pack:distribute!important;justify-content:space-around!important}.align-items-start{-ms-flex-align:start!important;align-items:flex-start!important}.align-items-end{-ms-flex-align:end!important;align-items:flex-end!important}.align-items-center{-ms-flex-align:center!important;align-items:center!important}.align-items-baseline{-ms-flex-align:baseline!important;align-items:baseline!important}.align-items-stretch{-ms-flex-align:stretch!important;align-items:stretch!important}.align-content-start{-ms-flex-line-pack:start!important;align-content:flex-start!important}.align-content-end{-ms-flex-line-pack:end!important;align-content:flex-end!important}.align-content-center{-ms-flex-line-pack:center!important;align-content:center!important}.align-content-between{-ms-flex-line-pack:justify!important;align-content:space-between!important}.align-content-around{-ms-flex-line-pack:distribute!important;align-content:space-around!important}.align-content-stretch{-ms-flex-line-pack:stretch!important;align-content:stretch!important}.align-self-auto{-ms-flex-item-align:auto!important;align-self:auto!important}.align-self-start{-ms-flex-item-align:start!important;align-self:flex-start!important}.align-self-end{-ms-flex-item-align:end!important;align-self:flex-end!important}.align-self-center{-ms-flex-item-align:center!important;align-self:center!important}.align-self-baseline{-ms-flex-item-align:baseline!important;align-self:baseline!important}.align-self-stretch{-ms-flex-item-align:stretch!important;align-self:stretch!important}@media (min-width:576px){.flex-sm-row{-ms-flex-direction:row!important;flex-direction:row!important}.flex-sm-column{-ms-flex-direction:column!important;flex-direction:column!important}.flex-sm-row-reverse{-ms-flex-direction:row-reverse!important;flex-direction:row-reverse!important}.flex-sm-column-reverse{-ms-flex-direction:column-reverse!important;flex-direction:column-reverse!important}.flex-sm-wrap{-ms-flex-wrap:wrap!important;flex-wrap:wrap!important}.flex-sm-nowrap{-ms-flex-wrap:nowrap!important;flex-wrap:nowrap!important}.flex-sm-wrap-reverse{-ms-flex-wrap:wrap-reverse!important;flex-wrap:wrap-reverse!important}.flex-sm-fill{-ms-flex:1 1 auto!important;flex:1 1 auto!important}.flex-sm-grow-0{-ms-flex-positive:0!important;flex-grow:0!important}.flex-sm-grow-1{-ms-flex-positive:1!important;flex-grow:1!important}.flex-sm-shrink-0{-ms-flex-negative:0!important;flex-shrink:0!important}.flex-sm-shrink-1{-ms-flex-negative:1!important;flex-shrink:1!important}.justify-content-sm-start{-ms-flex-pack:start!important;justify-content:flex-start!important}.justify-content-sm-end{-ms-flex-pack:end!important;justify-content:flex-end!important}.justify-content-sm-center{-ms-flex-pack:center!important;justify-content:center!important}.justify-content-sm-between{-ms-flex-pack:justify!important;justify-content:space-between!important}.justify-content-sm-around{-ms-flex-pack:distribute!important;justify-content:space-around!important}.align-items-sm-start{-ms-flex-align:start!important;align-items:flex-start!important}.align-items-sm-end{-ms-flex-align:end!important;align-items:flex-end!important}.align-items-sm-center{-ms-flex-align:center!important;align-items:center!important}.align-items-sm-baseline{-ms-flex-align:baseline!important;align-items:baseline!important}.align-items-sm-stretch{-ms-flex-align:stretch!important;align-items:stretch!important}.align-content-sm-start{-ms-flex-line-pack:start!important;align-content:flex-start!important}.align-content-sm-end{-ms-flex-line-pack:end!important;align-content:flex-end!important}.align-content-sm-center{-ms-flex-line-pack:center!important;align-content:center!important}.align-content-sm-between{-ms-flex-line-pack:justify!important;align-content:space-between!important}.align-content-sm-around{-ms-flex-line-pack:distribute!important;align-content:space-around!important}.align-content-sm-stretch{-ms-flex-line-pack:stretch!important;align-content:stretch!important}.align-self-sm-auto{-ms-flex-item-align:auto!important;align-self:auto!important}.align-self-sm-start{-ms-flex-item-align:start!important;align-self:flex-start!important}.align-self-sm-end{-ms-flex-item-align:end!important;align-self:flex-end!important}.align-self-sm-center{-ms-flex-item-align:center!important;align-self:center!important}.align-self-sm-baseline{-ms-flex-item-align:baseline!important;align-self:baseline!important}.align-self-sm-stretch{-ms-flex-item-align:stretch!important;align-self:stretch!important}}@media (min-width:768px){.flex-md-row{-ms-flex-direction:row!important;flex-direction:row!important}.flex-md-column{-ms-flex-direction:column!important;flex-direction:column!important}.flex-md-row-reverse{-ms-flex-direction:row-reverse!important;flex-direction:row-reverse!important}.flex-md-column-reverse{-ms-flex-direction:column-reverse!important;flex-direction:column-reverse!important}.flex-md-wrap{-ms-flex-wrap:wrap!important;flex-wrap:wrap!important}.flex-md-nowrap{-ms-flex-wrap:nowrap!important;flex-wrap:nowrap!important}.flex-md-wrap-reverse{-ms-flex-wrap:wrap-reverse!important;flex-wrap:wrap-reverse!important}.flex-md-fill{-ms-flex:1 1 auto!important;flex:1 1 auto!important}.flex-md-grow-0{-ms-flex-positive:0!important;flex-grow:0!important}.flex-md-grow-1{-ms-flex-positive:1!important;flex-grow:1!important}.flex-md-shrink-0{-ms-flex-negative:0!important;flex-shrink:0!important}.flex-md-shrink-1{-ms-flex-negative:1!important;flex-shrink:1!important}.justify-content-md-start{-ms-flex-pack:start!important;justify-content:flex-start!important}.justify-content-md-end{-ms-flex-pack:end!important;justify-content:flex-end!important}.justify-content-md-center{-ms-flex-pack:center!important;justify-content:center!important}.justify-content-md-between{-ms-flex-pack:justify!important;justify-content:space-between!important}.justify-content-md-around{-ms-flex-pack:distribute!important;justify-content:space-around!important}.align-items-md-start{-ms-flex-align:start!important;align-items:flex-start!important}.align-items-md-end{-ms-flex-align:end!important;align-items:flex-end!important}.align-items-md-center{-ms-flex-align:center!important;align-items:center!important}.align-items-md-baseline{-ms-flex-align:baseline!important;align-items:baseline!important}.align-items-md-stretch{-ms-flex-align:stretch!important;align-items:stretch!important}.align-content-md-start{-ms-flex-line-pack:start!important;align-content:flex-start!important}.align-content-md-end{-ms-flex-line-pack:end!important;align-content:flex-end!important}.align-content-md-center{-ms-flex-line-pack:center!important;align-content:center!important}.align-content-md-between{-ms-flex-line-pack:justify!important;align-content:space-between!important}.align-content-md-around{-ms-flex-line-pack:distribute!important;align-content:space-around!important}.align-content-md-stretch{-ms-flex-line-pack:stretch!important;align-content:stretch!important}.align-self-md-auto{-ms-flex-item-align:auto!important;align-self:auto!important}.align-self-md-start{-ms-flex-item-align:start!important;align-self:flex-start!important}.align-self-md-end{-ms-flex-item-align:end!important;align-self:flex-end!important}.align-self-md-center{-ms-flex-item-align:center!important;align-self:center!important}.align-self-md-baseline{-ms-flex-item-align:baseline!important;align-self:baseline!important}.align-self-md-stretch{-ms-flex-item-align:stretch!important;align-self:stretch!important}}@media (min-width:992px){.flex-lg-row{-ms-flex-direction:row!important;flex-direction:row!important}.flex-lg-column{-ms-flex-direction:column!important;flex-direction:column!important}.flex-lg-row-reverse{-ms-flex-direction:row-reverse!important;flex-direction:row-reverse!important}.flex-lg-column-reverse{-ms-flex-direction:column-reverse!important;flex-direction:column-reverse!important}.flex-lg-wrap{-ms-flex-wrap:wrap!important;flex-wrap:wrap!important}.flex-lg-nowrap{-ms-flex-wrap:nowrap!important;flex-wrap:nowrap!important}.flex-lg-wrap-reverse{-ms-flex-wrap:wrap-reverse!important;flex-wrap:wrap-reverse!important}.flex-lg-fill{-ms-flex:1 1 auto!important;flex:1 1 auto!important}.flex-lg-grow-0{-ms-flex-positive:0!important;flex-grow:0!important}.flex-lg-grow-1{-ms-flex-positive:1!important;flex-grow:1!important}.flex-lg-shrink-0{-ms-flex-negative:0!important;flex-shrink:0!important}.flex-lg-shrink-1{-ms-flex-negative:1!important;flex-shrink:1!important}.justify-content-lg-start{-ms-flex-pack:start!important;justify-content:flex-start!important}.justify-content-lg-end{-ms-flex-pack:end!important;justify-content:flex-end!important}.justify-content-lg-center{-ms-flex-pack:center!important;justify-content:center!important}.justify-content-lg-between{-ms-flex-pack:justify!important;justify-content:space-between!important}.justify-content-lg-around{-ms-flex-pack:distribute!important;justify-content:space-around!important}.align-items-lg-start{-ms-flex-align:start!important;align-items:flex-start!important}.align-items-lg-end{-ms-flex-align:end!important;align-items:flex-end!important}.align-items-lg-center{-ms-flex-align:center!important;align-items:center!important}.align-items-lg-baseline{-ms-flex-align:baseline!important;align-items:baseline!important}.align-items-lg-stretch{-ms-flex-align:stretch!important;align-items:stretch!important}.align-content-lg-start{-ms-flex-line-pack:start!important;align-content:flex-start!important}.align-content-lg-end{-ms-flex-line-pack:end!important;align-content:flex-end!important}.align-content-lg-center{-ms-flex-line-pack:center!important;align-content:center!important}.align-content-lg-between{-ms-flex-line-pack:justify!important;align-content:space-between!important}.align-content-lg-around{-ms-flex-line-pack:distribute!important;align-content:space-around!important}.align-content-lg-stretch{-ms-flex-line-pack:stretch!important;align-content:stretch!important}.align-self-lg-auto{-ms-flex-item-align:auto!important;align-self:auto!important}.align-self-lg-start{-ms-flex-item-align:start!important;align-self:flex-start!important}.align-self-lg-end{-ms-flex-item-align:end!important;align-self:flex-end!important}.align-self-lg-center{-ms-flex-item-align:center!important;align-self:center!important}.align-self-lg-baseline{-ms-flex-item-align:baseline!important;align-self:baseline!important}.align-self-lg-stretch{-ms-flex-item-align:stretch!important;align-self:stretch!important}}@media (min-width:1200px){.flex-xl-row{-ms-flex-direction:row!important;flex-direction:row!important}.flex-xl-column{-ms-flex-direction:column!important;flex-direction:column!important}.flex-xl-row-reverse{-ms-flex-direction:row-reverse!important;flex-direction:row-reverse!important}.flex-xl-column-reverse{-ms-flex-direction:column-reverse!important;flex-direction:column-reverse!important}.flex-xl-wrap{-ms-flex-wrap:wrap!important;flex-wrap:wrap!important}.flex-xl-nowrap{-ms-flex-wrap:nowrap!important;flex-wrap:nowrap!important}.flex-xl-wrap-reverse{-ms-flex-wrap:wrap-reverse!important;flex-wrap:wrap-reverse!important}.flex-xl-fill{-ms-flex:1 1 auto!important;flex:1 1 auto!important}.flex-xl-grow-0{-ms-flex-positive:0!important;flex-grow:0!important}.flex-xl-grow-1{-ms-flex-positive:1!important;flex-grow:1!important}.flex-xl-shrink-0{-ms-flex-negative:0!important;flex-shrink:0!important}.flex-xl-shrink-1{-ms-flex-negative:1!important;flex-shrink:1!important}.justify-content-xl-start{-ms-flex-pack:start!important;justify-content:flex-start!important}.justify-content-xl-end{-ms-flex-pack:end!important;justify-content:flex-end!important}.justify-content-xl-center{-ms-flex-pack:center!important;justify-content:center!important}.justify-content-xl-between{-ms-flex-pack:justify!important;justify-content:space-between!important}.justify-content-xl-around{-ms-flex-pack:distribute!important;justify-content:space-around!important}.align-items-xl-start{-ms-flex-align:start!important;align-items:flex-start!important}.align-items-xl-end{-ms-flex-align:end!important;align-items:flex-end!important}.align-items-xl-center{-ms-flex-align:center!important;align-items:center!important}.align-items-xl-baseline{-ms-flex-align:baseline!important;align-items:baseline!important}.align-items-xl-stretch{-ms-flex-align:stretch!important;align-items:stretch!important}.align-content-xl-start{-ms-flex-line-pack:start!important;align-content:flex-start!important}.align-content-xl-end{-ms-flex-line-pack:end!important;align-content:flex-end!important}.align-content-xl-center{-ms-flex-line-pack:center!important;align-content:center!important}.align-content-xl-between{-ms-flex-line-pack:justify!important;align-content:space-between!important}.align-content-xl-around{-ms-flex-line-pack:distribute!important;align-content:space-around!important}.align-content-xl-stretch{-ms-flex-line-pack:stretch!important;align-content:stretch!important}.align-self-xl-auto{-ms-flex-item-align:auto!important;align-self:auto!important}.align-self-xl-start{-ms-flex-item-align:start!important;align-self:flex-start!important}.align-self-xl-end{-ms-flex-item-align:end!important;align-self:flex-end!important}.align-self-xl-center{-ms-flex-item-align:center!important;align-self:center!important}.align-self-xl-baseline{-ms-flex-item-align:baseline!important;align-self:baseline!important}.align-self-xl-stretch{-ms-flex-item-align:stretch!important;align-self:stretch!important}}.float-left{float:left!important}.float-right{float:right!important}.float-none{float:none!important}@media (min-width:576px){.float-sm-left{float:left!important}.float-sm-right{float:right!important}.float-sm-none{float:none!important}}@media (min-width:768px){.float-md-left{float:left!important}.float-md-right{float:right!important}.float-md-none{float:none!important}}@media (min-width:992px){.float-lg-left{float:left!important}.float-lg-right{float:right!important}.float-lg-none{float:none!important}}@media (min-width:1200px){.float-xl-left{float:left!important}.float-xl-right{float:right!important}.float-xl-none{float:none!important}}.user-select-all{-webkit-user-select:all!important;-moz-user-select:all!important;-ms-user-select:all!important;user-select:all!important}.user-select-auto{-webkit-user-select:auto!important;-moz-user-select:auto!important;-ms-user-select:auto!important;user-select:auto!important}.user-select-none{-webkit-user-select:none!important;-moz-user-select:none!important;-ms-user-select:none!important;user-select:none!important}.overflow-auto{overflow:auto!important}.overflow-hidden{overflow:hidden!important}.position-static{position:static!important}.position-relative{position:relative!important}.position-absolute{position:absolute!important}.position-fixed{position:fixed!important}.position-sticky{position:-webkit-sticky!important;position:sticky!important}.fixed-top{position:fixed;top:0;right:0;left:0;z-index:1030}.fixed-bottom{position:fixed;right:0;bottom:0;left:0;z-index:1030}@supports ((position:-webkit-sticky) or (position:sticky)){.sticky-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.sr-only-focusable:active,.sr-only-focusable:focus{position:static;width:auto;height:auto;overflow:visible;clip:auto;white-space:normal}.shadow-sm{box-shadow:0 .125rem .25rem rgba(0,0,0,.075)!important}.shadow{box-shadow:0 .5rem 1rem rgba(0,0,0,.15)!important}.shadow-lg{box-shadow:0 1rem 3rem rgba(0,0,0,.175)!important}.shadow-none{box-shadow:none!important}.w-25{width:25%!important}.w-50{width:50%!important}.w-75{width:75%!important}.w-100{width:100%!important}.w-auto{width:auto!important}.h-25{height:25%!important}.h-50{height:50%!important}.h-75{height:75%!important}.h-100{height:100%!important}.h-auto{height:auto!important}.mw-100{max-width:100%!important}.mh-100{max-height:100%!important}.min-vw-100{min-width:100vw!important}.min-vh-100{min-height:100vh!important}.vw-100{width:100vw!important}.vh-100{height:100vh!important}.m-0{margin:0!important}.mt-0,.my-0{margin-top:0!important}.mr-0,.mx-0{margin-right:0!important}.mb-0,.my-0{margin-bottom:0!important}.ml-0,.mx-0{margin-left:0!important}.m-1{margin:.25rem!important}.mt-1,.my-1{margin-top:.25rem!important}.mr-1,.mx-1{margin-right:.25rem!important}.mb-1,.my-1{margin-bottom:.25rem!important}.ml-1,.mx-1{margin-left:.25rem!important}.m-2{margin:.5rem!important}.mt-2,.my-2{margin-top:.5rem!important}.mr-2,.mx-2{margin-right:.5rem!important}.mb-2,.my-2{margin-bottom:.5rem!important}.ml-2,.mx-2{margin-left:.5rem!important}.m-3{margin:1rem!important}.mt-3,.my-3{margin-top:1rem!important}.mr-3,.mx-3{margin-right:1rem!important}.mb-3,.my-3{margin-bottom:1rem!important}.ml-3,.mx-3{margin-left:1rem!important}.m-4{margin:1.5rem!important}.mt-4,.my-4{margin-top:1.5rem!important}.mr-4,.mx-4{margin-right:1.5rem!important}.mb-4,.my-4{margin-bottom:1.5rem!important}.ml-4,.mx-4{margin-left:1.5rem!important}.m-5{margin:3rem!important}.mt-5,.my-5{margin-top:3rem!important}.mr-5,.mx-5{margin-right:3rem!important}.mb-5,.my-5{margin-bottom:3rem!important}.ml-5,.mx-5{margin-left:3rem!important}.p-0{padding:0!important}.pt-0,.py-0{padding-top:0!important}.pr-0,.px-0{padding-right:0!important}.pb-0,.py-0{padding-bottom:0!important}.pl-0,.px-0{padding-left:0!important}.p-1{padding:.25rem!important}.pt-1,.py-1{padding-top:.25rem!important}.pr-1,.px-1{padding-right:.25rem!important}.pb-1,.py-1{padding-bottom:.25rem!important}.pl-1,.px-1{padding-left:.25rem!important}.p-2{padding:.5rem!important}.pt-2,.py-2{padding-top:.5rem!important}.pr-2,.px-2{padding-right:.5rem!important}.pb-2,.py-2{padding-bottom:.5rem!important}.pl-2,.px-2{padding-left:.5rem!important}.p-3{padding:1rem!important}.pt-3,.py-3{padding-top:1rem!important}.pr-3,.px-3{padding-right:1rem!important}.pb-3,.py-3{padding-bottom:1rem!important}.pl-3,.px-3{padding-left:1rem!important}.p-4{padding:1.5rem!important}.pt-4,.py-4{padding-top:1.5rem!important}.pr-4,.px-4{padding-right:1.5rem!important}.pb-4,.py-4{padding-bottom:1.5rem!important}.pl-4,.px-4{padding-left:1.5rem!important}.p-5{padding:3rem!important}.pt-5,.py-5{padding-top:3rem!important}.pr-5,.px-5{padding-right:3rem!important}.pb-5,.py-5{padding-bottom:3rem!important}.pl-5,.px-5{padding-left:3rem!important}.m-n1{margin:-.25rem!important}.mt-n1,.my-n1{margin-top:-.25rem!important}.mr-n1,.mx-n1{margin-right:-.25rem!important}.mb-n1,.my-n1{margin-bottom:-.25rem!important}.ml-n1,.mx-n1{margin-left:-.25rem!important}.m-n2{margin:-.5rem!important}.mt-n2,.my-n2{margin-top:-.5rem!important}.mr-n2,.mx-n2{margin-right:-.5rem!important}.mb-n2,.my-n2{margin-bottom:-.5rem!important}.ml-n2,.mx-n2{margin-left:-.5rem!important}.m-n3{margin:-1rem!important}.mt-n3,.my-n3{margin-top:-1rem!important}.mr-n3,.mx-n3{margin-right:-1rem!important}.mb-n3,.my-n3{margin-bottom:-1rem!important}.ml-n3,.mx-n3{margin-left:-1rem!important}.m-n4{margin:-1.5rem!important}.mt-n4,.my-n4{margin-top:-1.5rem!important}.mr-n4,.mx-n4{margin-right:-1.5rem!important}.mb-n4,.my-n4{margin-bottom:-1.5rem!important}.ml-n4,.mx-n4{margin-left:-1.5rem!important}.m-n5{margin:-3rem!important}.mt-n5,.my-n5{margin-top:-3rem!important}.mr-n5,.mx-n5{margin-right:-3rem!important}.mb-n5,.my-n5{margin-bottom:-3rem!important}.ml-n5,.mx-n5{margin-left:-3rem!important}.m-auto{margin:auto!important}.mt-auto,.my-auto{margin-top:auto!important}.mr-auto,.mx-auto{margin-right:auto!important}.mb-auto,.my-auto{margin-bottom:auto!important}.ml-auto,.mx-auto{margin-left:auto!important}@media (min-width:576px){.m-sm-0{margin:0!important}.mt-sm-0,.my-sm-0{margin-top:0!important}.mr-sm-0,.mx-sm-0{margin-right:0!important}.mb-sm-0,.my-sm-0{margin-bottom:0!important}.ml-sm-0,.mx-sm-0{margin-left:0!important}.m-sm-1{margin:.25rem!important}.mt-sm-1,.my-sm-1{margin-top:.25rem!important}.mr-sm-1,.mx-sm-1{margin-right:.25rem!important}.mb-sm-1,.my-sm-1{margin-bottom:.25rem!important}.ml-sm-1,.mx-sm-1{margin-left:.25rem!important}.m-sm-2{margin:.5rem!important}.mt-sm-2,.my-sm-2{margin-top:.5rem!important}.mr-sm-2,.mx-sm-2{margin-right:.5rem!important}.mb-sm-2,.my-sm-2{margin-bottom:.5rem!important}.ml-sm-2,.mx-sm-2{margin-left:.5rem!important}.m-sm-3{margin:1rem!important}.mt-sm-3,.my-sm-3{margin-top:1rem!important}.mr-sm-3,.mx-sm-3{margin-right:1rem!important}.mb-sm-3,.my-sm-3{margin-bottom:1rem!important}.ml-sm-3,.mx-sm-3{margin-left:1rem!important}.m-sm-4{margin:1.5rem!important}.mt-sm-4,.my-sm-4{margin-top:1.5rem!important}.mr-sm-4,.mx-sm-4{margin-right:1.5rem!important}.mb-sm-4,.my-sm-4{margin-bottom:1.5rem!important}.ml-sm-4,.mx-sm-4{margin-left:1.5rem!important}.m-sm-5{margin:3rem!important}.mt-sm-5,.my-sm-5{margin-top:3rem!important}.mr-sm-5,.mx-sm-5{margin-right:3rem!important}.mb-sm-5,.my-sm-5{margin-bottom:3rem!important}.ml-sm-5,.mx-sm-5{margin-left:3rem!important}.p-sm-0{padding:0!important}.pt-sm-0,.py-sm-0{padding-top:0!important}.pr-sm-0,.px-sm-0{padding-right:0!important}.pb-sm-0,.py-sm-0{padding-bottom:0!important}.pl-sm-0,.px-sm-0{padding-left:0!important}.p-sm-1{padding:.25rem!important}.pt-sm-1,.py-sm-1{padding-top:.25rem!important}.pr-sm-1,.px-sm-1{padding-right:.25rem!important}.pb-sm-1,.py-sm-1{padding-bottom:.25rem!important}.pl-sm-1,.px-sm-1{padding-left:.25rem!important}.p-sm-2{padding:.5rem!important}.pt-sm-2,.py-sm-2{padding-top:.5rem!important}.pr-sm-2,.px-sm-2{padding-right:.5rem!important}.pb-sm-2,.py-sm-2{padding-bottom:.5rem!important}.pl-sm-2,.px-sm-2{padding-left:.5rem!important}.p-sm-3{padding:1rem!important}.pt-sm-3,.py-sm-3{padding-top:1rem!important}.pr-sm-3,.px-sm-3{padding-right:1rem!important}.pb-sm-3,.py-sm-3{padding-bottom:1rem!important}.pl-sm-3,.px-sm-3{padding-left:1rem!important}.p-sm-4{padding:1.5rem!important}.pt-sm-4,.py-sm-4{padding-top:1.5rem!important}.pr-sm-4,.px-sm-4{padding-right:1.5rem!important}.pb-sm-4,.py-sm-4{padding-bottom:1.5rem!important}.pl-sm-4,.px-sm-4{padding-left:1.5rem!important}.p-sm-5{padding:3rem!important}.pt-sm-5,.py-sm-5{padding-top:3rem!important}.pr-sm-5,.px-sm-5{padding-right:3rem!important}.pb-sm-5,.py-sm-5{padding-bottom:3rem!important}.pl-sm-5,.px-sm-5{padding-left:3rem!important}.m-sm-n1{margin:-.25rem!important}.mt-sm-n1,.my-sm-n1{margin-top:-.25rem!important}.mr-sm-n1,.mx-sm-n1{margin-right:-.25rem!important}.mb-sm-n1,.my-sm-n1{margin-bottom:-.25rem!important}.ml-sm-n1,.mx-sm-n1{margin-left:-.25rem!important}.m-sm-n2{margin:-.5rem!important}.mt-sm-n2,.my-sm-n2{margin-top:-.5rem!important}.mr-sm-n2,.mx-sm-n2{margin-right:-.5rem!important}.mb-sm-n2,.my-sm-n2{margin-bottom:-.5rem!important}.ml-sm-n2,.mx-sm-n2{margin-left:-.5rem!important}.m-sm-n3{margin:-1rem!important}.mt-sm-n3,.my-sm-n3{margin-top:-1rem!important}.mr-sm-n3,.mx-sm-n3{margin-right:-1rem!important}.mb-sm-n3,.my-sm-n3{margin-bottom:-1rem!important}.ml-sm-n3,.mx-sm-n3{margin-left:-1rem!important}.m-sm-n4{margin:-1.5rem!important}.mt-sm-n4,.my-sm-n4{margin-top:-1.5rem!important}.mr-sm-n4,.mx-sm-n4{margin-right:-1.5rem!important}.mb-sm-n4,.my-sm-n4{margin-bottom:-1.5rem!important}.ml-sm-n4,.mx-sm-n4{margin-left:-1.5rem!important}.m-sm-n5{margin:-3rem!important}.mt-sm-n5,.my-sm-n5{margin-top:-3rem!important}.mr-sm-n5,.mx-sm-n5{margin-right:-3rem!important}.mb-sm-n5,.my-sm-n5{margin-bottom:-3rem!important}.ml-sm-n5,.mx-sm-n5{margin-left:-3rem!important}.m-sm-auto{margin:auto!important}.mt-sm-auto,.my-sm-auto{margin-top:auto!important}.mr-sm-auto,.mx-sm-auto{margin-right:auto!important}.mb-sm-auto,.my-sm-auto{margin-bottom:auto!important}.ml-sm-auto,.mx-sm-auto{margin-left:auto!important}}@media (min-width:768px){.m-md-0{margin:0!important}.mt-md-0,.my-md-0{margin-top:0!important}.mr-md-0,.mx-md-0{margin-right:0!important}.mb-md-0,.my-md-0{margin-bottom:0!important}.ml-md-0,.mx-md-0{margin-left:0!important}.m-md-1{margin:.25rem!important}.mt-md-1,.my-md-1{margin-top:.25rem!important}.mr-md-1,.mx-md-1{margin-right:.25rem!important}.mb-md-1,.my-md-1{margin-bottom:.25rem!important}.ml-md-1,.mx-md-1{margin-left:.25rem!important}.m-md-2{margin:.5rem!important}.mt-md-2,.my-md-2{margin-top:.5rem!important}.mr-md-2,.mx-md-2{margin-right:.5rem!important}.mb-md-2,.my-md-2{margin-bottom:.5rem!important}.ml-md-2,.mx-md-2{margin-left:.5rem!important}.m-md-3{margin:1rem!important}.mt-md-3,.my-md-3{margin-top:1rem!important}.mr-md-3,.mx-md-3{margin-right:1rem!important}.mb-md-3,.my-md-3{margin-bottom:1rem!important}.ml-md-3,.mx-md-3{margin-left:1rem!important}.m-md-4{margin:1.5rem!important}.mt-md-4,.my-md-4{margin-top:1.5rem!important}.mr-md-4,.mx-md-4{margin-right:1.5rem!important}.mb-md-4,.my-md-4{margin-bottom:1.5rem!important}.ml-md-4,.mx-md-4{margin-left:1.5rem!important}.m-md-5{margin:3rem!important}.mt-md-5,.my-md-5{margin-top:3rem!important}.mr-md-5,.mx-md-5{margin-right:3rem!important}.mb-md-5,.my-md-5{margin-bottom:3rem!important}.ml-md-5,.mx-md-5{margin-left:3rem!important}.p-md-0{padding:0!important}.pt-md-0,.py-md-0{padding-top:0!important}.pr-md-0,.px-md-0{padding-right:0!important}.pb-md-0,.py-md-0{padding-bottom:0!important}.pl-md-0,.px-md-0{padding-left:0!important}.p-md-1{padding:.25rem!important}.pt-md-1,.py-md-1{padding-top:.25rem!important}.pr-md-1,.px-md-1{padding-right:.25rem!important}.pb-md-1,.py-md-1{padding-bottom:.25rem!important}.pl-md-1,.px-md-1{padding-left:.25rem!important}.p-md-2{padding:.5rem!important}.pt-md-2,.py-md-2{padding-top:.5rem!important}.pr-md-2,.px-md-2{padding-right:.5rem!important}.pb-md-2,.py-md-2{padding-bottom:.5rem!important}.pl-md-2,.px-md-2{padding-left:.5rem!important}.p-md-3{padding:1rem!important}.pt-md-3,.py-md-3{padding-top:1rem!important}.pr-md-3,.px-md-3{padding-right:1rem!important}.pb-md-3,.py-md-3{padding-bottom:1rem!important}.pl-md-3,.px-md-3{padding-left:1rem!important}.p-md-4{padding:1.5rem!important}.pt-md-4,.py-md-4{padding-top:1.5rem!important}.pr-md-4,.px-md-4{padding-right:1.5rem!important}.pb-md-4,.py-md-4{padding-bottom:1.5rem!important}.pl-md-4,.px-md-4{padding-left:1.5rem!important}.p-md-5{padding:3rem!important}.pt-md-5,.py-md-5{padding-top:3rem!important}.pr-md-5,.px-md-5{padding-right:3rem!important}.pb-md-5,.py-md-5{padding-bottom:3rem!important}.pl-md-5,.px-md-5{padding-left:3rem!important}.m-md-n1{margin:-.25rem!important}.mt-md-n1,.my-md-n1{margin-top:-.25rem!important}.mr-md-n1,.mx-md-n1{margin-right:-.25rem!important}.mb-md-n1,.my-md-n1{margin-bottom:-.25rem!important}.ml-md-n1,.mx-md-n1{margin-left:-.25rem!important}.m-md-n2{margin:-.5rem!important}.mt-md-n2,.my-md-n2{margin-top:-.5rem!important}.mr-md-n2,.mx-md-n2{margin-right:-.5rem!important}.mb-md-n2,.my-md-n2{margin-bottom:-.5rem!important}.ml-md-n2,.mx-md-n2{margin-left:-.5rem!important}.m-md-n3{margin:-1rem!important}.mt-md-n3,.my-md-n3{margin-top:-1rem!important}.mr-md-n3,.mx-md-n3{margin-right:-1rem!important}.mb-md-n3,.my-md-n3{margin-bottom:-1rem!important}.ml-md-n3,.mx-md-n3{margin-left:-1rem!important}.m-md-n4{margin:-1.5rem!important}.mt-md-n4,.my-md-n4{margin-top:-1.5rem!important}.mr-md-n4,.mx-md-n4{margin-right:-1.5rem!important}.mb-md-n4,.my-md-n4{margin-bottom:-1.5rem!important}.ml-md-n4,.mx-md-n4{margin-left:-1.5rem!important}.m-md-n5{margin:-3rem!important}.mt-md-n5,.my-md-n5{margin-top:-3rem!important}.mr-md-n5,.mx-md-n5{margin-right:-3rem!important}.mb-md-n5,.my-md-n5{margin-bottom:-3rem!important}.ml-md-n5,.mx-md-n5{margin-left:-3rem!important}.m-md-auto{margin:auto!important}.mt-md-auto,.my-md-auto{margin-top:auto!important}.mr-md-auto,.mx-md-auto{margin-right:auto!important}.mb-md-auto,.my-md-auto{margin-bottom:auto!important}.ml-md-auto,.mx-md-auto{margin-left:auto!important}}@media (min-width:992px){.m-lg-0{margin:0!important}.mt-lg-0,.my-lg-0{margin-top:0!important}.mr-lg-0,.mx-lg-0{margin-right:0!important}.mb-lg-0,.my-lg-0{margin-bottom:0!important}.ml-lg-0,.mx-lg-0{margin-left:0!important}.m-lg-1{margin:.25rem!important}.mt-lg-1,.my-lg-1{margin-top:.25rem!important}.mr-lg-1,.mx-lg-1{margin-right:.25rem!important}.mb-lg-1,.my-lg-1{margin-bottom:.25rem!important}.ml-lg-1,.mx-lg-1{margin-left:.25rem!important}.m-lg-2{margin:.5rem!important}.mt-lg-2,.my-lg-2{margin-top:.5rem!important}.mr-lg-2,.mx-lg-2{margin-right:.5rem!important}.mb-lg-2,.my-lg-2{margin-bottom:.5rem!important}.ml-lg-2,.mx-lg-2{margin-left:.5rem!important}.m-lg-3{margin:1rem!important}.mt-lg-3,.my-lg-3{margin-top:1rem!important}.mr-lg-3,.mx-lg-3{margin-right:1rem!important}.mb-lg-3,.my-lg-3{margin-bottom:1rem!important}.ml-lg-3,.mx-lg-3{margin-left:1rem!important}.m-lg-4{margin:1.5rem!important}.mt-lg-4,.my-lg-4{margin-top:1.5rem!important}.mr-lg-4,.mx-lg-4{margin-right:1.5rem!important}.mb-lg-4,.my-lg-4{margin-bottom:1.5rem!important}.ml-lg-4,.mx-lg-4{margin-left:1.5rem!important}.m-lg-5{margin:3rem!important}.mt-lg-5,.my-lg-5{margin-top:3rem!important}.mr-lg-5,.mx-lg-5{margin-right:3rem!important}.mb-lg-5,.my-lg-5{margin-bottom:3rem!important}.ml-lg-5,.mx-lg-5{margin-left:3rem!important}.p-lg-0{padding:0!important}.pt-lg-0,.py-lg-0{padding-top:0!important}.pr-lg-0,.px-lg-0{padding-right:0!important}.pb-lg-0,.py-lg-0{padding-bottom:0!important}.pl-lg-0,.px-lg-0{padding-left:0!important}.p-lg-1{padding:.25rem!important}.pt-lg-1,.py-lg-1{padding-top:.25rem!important}.pr-lg-1,.px-lg-1{padding-right:.25rem!important}.pb-lg-1,.py-lg-1{padding-bottom:.25rem!important}.pl-lg-1,.px-lg-1{padding-left:.25rem!important}.p-lg-2{padding:.5rem!important}.pt-lg-2,.py-lg-2{padding-top:.5rem!important}.pr-lg-2,.px-lg-2{padding-right:.5rem!important}.pb-lg-2,.py-lg-2{padding-bottom:.5rem!important}.pl-lg-2,.px-lg-2{padding-left:.5rem!important}.p-lg-3{padding:1rem!important}.pt-lg-3,.py-lg-3{padding-top:1rem!important}.pr-lg-3,.px-lg-3{padding-right:1rem!important}.pb-lg-3,.py-lg-3{padding-bottom:1rem!important}.pl-lg-3,.px-lg-3{padding-left:1rem!important}.p-lg-4{padding:1.5rem!important}.pt-lg-4,.py-lg-4{padding-top:1.5rem!important}.pr-lg-4,.px-lg-4{padding-right:1.5rem!important}.pb-lg-4,.py-lg-4{padding-bottom:1.5rem!important}.pl-lg-4,.px-lg-4{padding-left:1.5rem!important}.p-lg-5{padding:3rem!important}.pt-lg-5,.py-lg-5{padding-top:3rem!important}.pr-lg-5,.px-lg-5{padding-right:3rem!important}.pb-lg-5,.py-lg-5{padding-bottom:3rem!important}.pl-lg-5,.px-lg-5{padding-left:3rem!important}.m-lg-n1{margin:-.25rem!important}.mt-lg-n1,.my-lg-n1{margin-top:-.25rem!important}.mr-lg-n1,.mx-lg-n1{margin-right:-.25rem!important}.mb-lg-n1,.my-lg-n1{margin-bottom:-.25rem!important}.ml-lg-n1,.mx-lg-n1{margin-left:-.25rem!important}.m-lg-n2{margin:-.5rem!important}.mt-lg-n2,.my-lg-n2{margin-top:-.5rem!important}.mr-lg-n2,.mx-lg-n2{margin-right:-.5rem!important}.mb-lg-n2,.my-lg-n2{margin-bottom:-.5rem!important}.ml-lg-n2,.mx-lg-n2{margin-left:-.5rem!important}.m-lg-n3{margin:-1rem!important}.mt-lg-n3,.my-lg-n3{margin-top:-1rem!important}.mr-lg-n3,.mx-lg-n3{margin-right:-1rem!important}.mb-lg-n3,.my-lg-n3{margin-bottom:-1rem!important}.ml-lg-n3,.mx-lg-n3{margin-left:-1rem!important}.m-lg-n4{margin:-1.5rem!important}.mt-lg-n4,.my-lg-n4{margin-top:-1.5rem!important}.mr-lg-n4,.mx-lg-n4{margin-right:-1.5rem!important}.mb-lg-n4,.my-lg-n4{margin-bottom:-1.5rem!important}.ml-lg-n4,.mx-lg-n4{margin-left:-1.5rem!important}.m-lg-n5{margin:-3rem!important}.mt-lg-n5,.my-lg-n5{margin-top:-3rem!important}.mr-lg-n5,.mx-lg-n5{margin-right:-3rem!important}.mb-lg-n5,.my-lg-n5{margin-bottom:-3rem!important}.ml-lg-n5,.mx-lg-n5{margin-left:-3rem!important}.m-lg-auto{margin:auto!important}.mt-lg-auto,.my-lg-auto{margin-top:auto!important}.mr-lg-auto,.mx-lg-auto{margin-right:auto!important}.mb-lg-auto,.my-lg-auto{margin-bottom:auto!important}.ml-lg-auto,.mx-lg-auto{margin-left:auto!important}}@media (min-width:1200px){.m-xl-0{margin:0!important}.mt-xl-0,.my-xl-0{margin-top:0!important}.mr-xl-0,.mx-xl-0{margin-right:0!important}.mb-xl-0,.my-xl-0{margin-bottom:0!important}.ml-xl-0,.mx-xl-0{margin-left:0!important}.m-xl-1{margin:.25rem!important}.mt-xl-1,.my-xl-1{margin-top:.25rem!important}.mr-xl-1,.mx-xl-1{margin-right:.25rem!important}.mb-xl-1,.my-xl-1{margin-bottom:.25rem!important}.ml-xl-1,.mx-xl-1{margin-left:.25rem!important}.m-xl-2{margin:.5rem!important}.mt-xl-2,.my-xl-2{margin-top:.5rem!important}.mr-xl-2,.mx-xl-2{margin-right:.5rem!important}.mb-xl-2,.my-xl-2{margin-bottom:.5rem!important}.ml-xl-2,.mx-xl-2{margin-left:.5rem!important}.m-xl-3{margin:1rem!important}.mt-xl-3,.my-xl-3{margin-top:1rem!important}.mr-xl-3,.mx-xl-3{margin-right:1rem!important}.mb-xl-3,.my-xl-3{margin-bottom:1rem!important}.ml-xl-3,.mx-xl-3{margin-left:1rem!important}.m-xl-4{margin:1.5rem!important}.mt-xl-4,.my-xl-4{margin-top:1.5rem!important}.mr-xl-4,.mx-xl-4{margin-right:1.5rem!important}.mb-xl-4,.my-xl-4{margin-bottom:1.5rem!important}.ml-xl-4,.mx-xl-4{margin-left:1.5rem!important}.m-xl-5{margin:3rem!important}.mt-xl-5,.my-xl-5{margin-top:3rem!important}.mr-xl-5,.mx-xl-5{margin-right:3rem!important}.mb-xl-5,.my-xl-5{margin-bottom:3rem!important}.ml-xl-5,.mx-xl-5{margin-left:3rem!important}.p-xl-0{padding:0!important}.pt-xl-0,.py-xl-0{padding-top:0!important}.pr-xl-0,.px-xl-0{padding-right:0!important}.pb-xl-0,.py-xl-0{padding-bottom:0!important}.pl-xl-0,.px-xl-0{padding-left:0!important}.p-xl-1{padding:.25rem!important}.pt-xl-1,.py-xl-1{padding-top:.25rem!important}.pr-xl-1,.px-xl-1{padding-right:.25rem!important}.pb-xl-1,.py-xl-1{padding-bottom:.25rem!important}.pl-xl-1,.px-xl-1{padding-left:.25rem!important}.p-xl-2{padding:.5rem!important}.pt-xl-2,.py-xl-2{padding-top:.5rem!important}.pr-xl-2,.px-xl-2{padding-right:.5rem!important}.pb-xl-2,.py-xl-2{padding-bottom:.5rem!important}.pl-xl-2,.px-xl-2{padding-left:.5rem!important}.p-xl-3{padding:1rem!important}.pt-xl-3,.py-xl-3{padding-top:1rem!important}.pr-xl-3,.px-xl-3{padding-right:1rem!important}.pb-xl-3,.py-xl-3{padding-bottom:1rem!important}.pl-xl-3,.px-xl-3{padding-left:1rem!important}.p-xl-4{padding:1.5rem!important}.pt-xl-4,.py-xl-4{padding-top:1.5rem!important}.pr-xl-4,.px-xl-4{padding-right:1.5rem!important}.pb-xl-4,.py-xl-4{padding-bottom:1.5rem!important}.pl-xl-4,.px-xl-4{padding-left:1.5rem!important}.p-xl-5{padding:3rem!important}.pt-xl-5,.py-xl-5{padding-top:3rem!important}.pr-xl-5,.px-xl-5{padding-right:3rem!important}.pb-xl-5,.py-xl-5{padding-bottom:3rem!important}.pl-xl-5,.px-xl-5{padding-left:3rem!important}.m-xl-n1{margin:-.25rem!important}.mt-xl-n1,.my-xl-n1{margin-top:-.25rem!important}.mr-xl-n1,.mx-xl-n1{margin-right:-.25rem!important}.mb-xl-n1,.my-xl-n1{margin-bottom:-.25rem!important}.ml-xl-n1,.mx-xl-n1{margin-left:-.25rem!important}.m-xl-n2{margin:-.5rem!important}.mt-xl-n2,.my-xl-n2{margin-top:-.5rem!important}.mr-xl-n2,.mx-xl-n2{margin-right:-.5rem!important}.mb-xl-n2,.my-xl-n2{margin-bottom:-.5rem!important}.ml-xl-n2,.mx-xl-n2{margin-left:-.5rem!important}.m-xl-n3{margin:-1rem!important}.mt-xl-n3,.my-xl-n3{margin-top:-1rem!important}.mr-xl-n3,.mx-xl-n3{margin-right:-1rem!important}.mb-xl-n3,.my-xl-n3{margin-bottom:-1rem!important}.ml-xl-n3,.mx-xl-n3{margin-left:-1rem!important}.m-xl-n4{margin:-1.5rem!important}.mt-xl-n4,.my-xl-n4{margin-top:-1.5rem!important}.mr-xl-n4,.mx-xl-n4{margin-right:-1.5rem!important}.mb-xl-n4,.my-xl-n4{margin-bottom:-1.5rem!important}.ml-xl-n4,.mx-xl-n4{margin-left:-1.5rem!important}.m-xl-n5{margin:-3rem!important}.mt-xl-n5,.my-xl-n5{margin-top:-3rem!important}.mr-xl-n5,.mx-xl-n5{margin-right:-3rem!important}.mb-xl-n5,.my-xl-n5{margin-bottom:-3rem!important}.ml-xl-n5,.mx-xl-n5{margin-left:-3rem!important}.m-xl-auto{margin:auto!important}.mt-xl-auto,.my-xl-auto{margin-top:auto!important}.mr-xl-auto,.mx-xl-auto{margin-right:auto!important}.mb-xl-auto,.my-xl-auto{margin-bottom:auto!important}.ml-xl-auto,.mx-xl-auto{margin-left:auto!important}}.stretched-link::after{position:absolute;top:0;right:0;bottom:0;left:0;z-index:1;pointer-events:auto;content:"";background-color:rgba(0,0,0,0)}.text-monospace{font-family:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace!important}.text-justify{text-align:justify!important}.text-wrap{white-space:normal!important}.text-nowrap{white-space:nowrap!important}.text-truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.text-left{text-align:left!important}.text-right{text-align:right!important}.text-center{text-align:center!important}@media (min-width:576px){.text-sm-left{text-align:left!important}.text-sm-right{text-align:right!important}.text-sm-center{text-align:center!important}}@media (min-width:768px){.text-md-left{text-align:left!important}.text-md-right{text-align:right!important}.text-md-center{text-align:center!important}}@media (min-width:992px){.text-lg-left{text-align:left!important}.text-lg-right{text-align:right!important}.text-lg-center{text-align:center!important}}@media (min-width:1200px){.text-xl-left{text-align:left!important}.text-xl-right{text-align:right!important}.text-xl-center{text-align:center!important}}.text-lowercase{text-transform:lowercase!important}.text-uppercase{text-transform:uppercase!important}.text-capitalize{text-transform:capitalize!important}.font-weight-light{font-weight:300!important}.font-weight-lighter{font-weight:lighter!important}.font-weight-normal{font-weight:400!important}.font-weight-bold{font-weight:700!important}.font-weight-bolder{font-weight:bolder!important}.font-italic{font-style:italic!important}.text-white{color:#fff!important}.text-primary{color:#007bff!important}a.text-primary:focus,a.text-primary:hover{color:#0056b3!important}.text-secondary{color:#6c757d!important}a.text-secondary:focus,a.text-secondary:hover{color:#494f54!important}.text-success{color:#28a745!important}a.text-success:focus,a.text-success:hover{color:#19692c!important}.text-info{color:#17a2b8!important}a.text-info:focus,a.text-info:hover{color:#0f6674!important}.text-warning{color:#ffc107!important}a.text-warning:focus,a.text-warning:hover{color:#ba8b00!important}.text-danger{color:#dc3545!important}a.text-danger:focus,a.text-danger:hover{color:#a71d2a!important}.text-light{color:#f8f9fa!important}a.text-light:focus,a.text-light:hover{color:#cbd3da!important}.text-dark{color:#343a40!important}a.text-dark:focus,a.text-dark:hover{color:#121416!important}.text-body{color:#212529!important}.text-muted{color:#6c757d!important}.text-black-50{color:rgba(0,0,0,.5)!important}.text-white-50{color:rgba(255,255,255,.5)!important}.text-hide{font:0/0 a;color:transparent;text-shadow:none;background-color:transparent;border:0}.text-decoration-none{text-decoration:none!important}.text-break{word-break:break-word!important;overflow-wrap:break-word!important}.text-reset{color:inherit!important}.visible{visibility:visible!important}.invisible{visibility:hidden!important}@media print{*,::after,::before{text-shadow:none!important;box-shadow:none!important}a:not(.btn){text-decoration:underline}abbr[title]::after{content:" (" attr(title) ")"}pre{white-space:pre-wrap!important}blockquote,pre{border:1px solid #adb5bd;page-break-inside:avoid}thead{display:table-header-group}img,tr{page-break-inside:avoid}h2,h3,p{orphans:3;widows:3}h2,h3{page-break-after:avoid}@page{size:a3}body{min-width:992px!important}.container{min-width:992px!important}.navbar{display:none}.badge{border:1px solid #000}.table{border-collapse:collapse!important}.table td,.table th{background-color:#fff!important}.table-bordered td,.table-bordered th{border:1px solid #dee2e6!important}.table-dark{color:inherit}.table-dark tbody+tbody,.table-dark td,.table-dark th,.table-dark thead th{border-color:#dee2e6}.table .thead-dark th{color:inherit;border-color:#dee2e6}} +/*# sourceMappingURL=bootstrap.min.css.map */ \ No newline at end of file From 7b0810413ab8437df28c6e349b84fcecc8a46fd9 Mon Sep 17 00:00:00 2001 From: Ben Rometsch Date: Wed, 18 Nov 2020 10:17:56 +0000 Subject: [PATCH 08/47] Handle black library for builds --- .gitlab-ci.yml | 2 +- Pipfile.lock | 469 +++++++++++++++++++++++++++++++++---------------- 2 files changed, 314 insertions(+), 157 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 5cf078b971c2..5d2207a80341 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -14,7 +14,7 @@ test: POSTGRES_USER: testuser POSTGRES_PASSWORD: testpass script: - - pipenv install --dev + - pipenv install --dev --pre - pipenv run test deploydevelop: diff --git a/Pipfile.lock b/Pipfile.lock index e99a6ae02c03..b1fad9b01170 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "4b1b7b71f8ada3fb50f375e931eb912c943df041b9f5ddb49a6b2504c59173ce" + "sha256": "5dc316c5f1c6d96ef50a52256ed643f5e3d7f449fb8117ca67bf02576e707b75" }, "pipfile-spec": 6, "requires": {}, @@ -22,19 +22,27 @@ "index": "pypi", "version": "==1.4.4" }, + "asgiref": { + "hashes": [ + "sha256:5ee950735509d04eb673bd7f7120f8fa1c9e2df495394992c73234d526907e17", + "sha256:7162a3cb30ab0609f1a4c95938fd73e8604f63bdba516a7f7d64b83ff09478f0" + ], + "markers": "python_version >= '3.5'", + "version": "==3.3.1" + }, "boto3": { "hashes": [ - "sha256:270ac22a66ce3313e908946193df6e0fb3e81cdf60f5113d62da1d8991b75030", - "sha256:e2857738affb394bbe96473de2ed01331685d6e313bb1a3328fd5f47841429cc" + "sha256:0a4c72ef591fd4291efb9767a6a94f625afccc1e0fc7843968cd39eb6289c3e4", + "sha256:6c5d952f97e13997b1c7463038d96469355595cd37f87b43451759cc03756322" ], - "version": "==1.16.3" + "version": "==1.16.20" }, "botocore": { "hashes": [ - "sha256:4ea4c74d244c1b4701387fd1abe6a5e1833dc621c6d39f8888f0bfa95ddd82f5", - "sha256:f5084376a8519332a200737f5cd80e87f47868b7da4d57fc192397670e0af022" + "sha256:00a69a507e8b817d0703c612131e6cb3a3260579d0353c56d005bd9effd92ec0", + "sha256:4576ca751264c65420daae07e244beafdfea493b4fbb815d37215c0736dc4633" ], - "version": "==1.19.3" + "version": "==1.19.20" }, "cachetools": { "hashes": [ @@ -46,10 +54,51 @@ }, "certifi": { "hashes": [ - "sha256:5930595817496dd21bb8dc35dad090f1c2cd0adfaf21204bf6732ca5d8ee34d3", - "sha256:8fc0819f1f30ba15bdb34cceffb9ef04d99f420f68eb75d901e9560b8749fc41" - ], - "version": "==2020.6.20" + "sha256:1f422849db327d534e3d0c5f02a263458c3955ec0aae4ff09b95f195c59f4edd", + "sha256:f05def092c44fbf25834a51509ef6e631dc19765ab8a57b4e7ab85531f0a9cf4" + ], + "version": "==2020.11.8" + }, + "cffi": { + "hashes": [ + "sha256:005f2bfe11b6745d726dbb07ace4d53f057de66e336ff92d61b8c7e9c8f4777d", + "sha256:09e96138280241bd355cd585148dec04dbbedb4f46128f340d696eaafc82dd7b", + "sha256:0b1ad452cc824665ddc682400b62c9e4f5b64736a2ba99110712fdee5f2505c4", + "sha256:0ef488305fdce2580c8b2708f22d7785ae222d9825d3094ab073e22e93dfe51f", + "sha256:15f351bed09897fbda218e4db5a3d5c06328862f6198d4fb385f3e14e19decb3", + "sha256:22399ff4870fb4c7ef19fff6eeb20a8bbf15571913c181c78cb361024d574579", + "sha256:23e5d2040367322824605bc29ae8ee9175200b92cb5483ac7d466927a9b3d537", + "sha256:2791f68edc5749024b4722500e86303a10d342527e1e3bcac47f35fbd25b764e", + "sha256:2f9674623ca39c9ebe38afa3da402e9326c245f0f5ceff0623dccdac15023e05", + "sha256:3363e77a6176afb8823b6e06db78c46dbc4c7813b00a41300a4873b6ba63b171", + "sha256:33c6cdc071ba5cd6d96769c8969a0531be2d08c2628a0143a10a7dcffa9719ca", + "sha256:3b8eaf915ddc0709779889c472e553f0d3e8b7bdf62dab764c8921b09bf94522", + "sha256:3cb3e1b9ec43256c4e0f8d2837267a70b0e1ca8c4f456685508ae6106b1f504c", + "sha256:3eeeb0405fd145e714f7633a5173318bd88d8bbfc3dd0a5751f8c4f70ae629bc", + "sha256:44f60519595eaca110f248e5017363d751b12782a6f2bd6a7041cba275215f5d", + "sha256:4d7c26bfc1ea9f92084a1d75e11999e97b62d63128bcc90c3624d07813c52808", + "sha256:529c4ed2e10437c205f38f3691a68be66c39197d01062618c55f74294a4a4828", + "sha256:6642f15ad963b5092d65aed022d033c77763515fdc07095208f15d3563003869", + "sha256:85ba797e1de5b48aa5a8427b6ba62cf69607c18c5d4eb747604b7302f1ec382d", + "sha256:8f0f1e499e4000c4c347a124fa6a27d37608ced4fe9f7d45070563b7c4c370c9", + "sha256:a624fae282e81ad2e4871bdb767e2c914d0539708c0f078b5b355258293c98b0", + "sha256:b0358e6fefc74a16f745afa366acc89f979040e0cbc4eec55ab26ad1f6a9bfbc", + "sha256:bbd2f4dfee1079f76943767fce837ade3087b578aeb9f69aec7857d5bf25db15", + "sha256:bf39a9e19ce7298f1bd6a9758fa99707e9e5b1ebe5e90f2c3913a47bc548747c", + "sha256:c11579638288e53fc94ad60022ff1b67865363e730ee41ad5e6f0a17188b327a", + "sha256:c150eaa3dadbb2b5339675b88d4573c1be3cb6f2c33a6c83387e10cc0bf05bd3", + "sha256:c53af463f4a40de78c58b8b2710ade243c81cbca641e34debf3396a9640d6ec1", + "sha256:cb763ceceae04803adcc4e2d80d611ef201c73da32d8f2722e9d0ab0c7f10768", + "sha256:cc75f58cdaf043fe6a7a6c04b3b5a0e694c6a9e24050967747251fb80d7bce0d", + "sha256:d80998ed59176e8cba74028762fbd9b9153b9afc71ea118e63bbf5d4d0f9552b", + "sha256:de31b5164d44ef4943db155b3e8e17929707cac1e5bd2f363e67a56e3af4af6e", + "sha256:e66399cf0fc07de4dce4f588fc25bfe84a6d1285cc544e67987d22663393926d", + "sha256:f0620511387790860b249b9241c2f13c3a80e21a73e0b861a2df24e9d6f56730", + "sha256:f4eae045e6ab2bb54ca279733fe4eb85f1effda392666308250714e01907f394", + "sha256:f92cdecb618e5fa4658aeb97d5eb3d2f47aa94ac6477c6daf0f306c5a3b9e6b1", + "sha256:f92f789e4f9241cd262ad7a555ca2c648a98178a953af117ef7fad46aa1d5591" + ], + "version": "==1.14.3" }, "chardet": { "hashes": [ @@ -60,10 +109,10 @@ }, "chargebee": { "hashes": [ - "sha256:1886d6d0dffae085a665f9a000e6faa0695c6669dbfd5c7e2fa12f526c75b563" + "sha256:95b1f7ff91737103f6115edb5f2329f525a4f181770597c6b9d2683628f9a92d" ], "index": "pypi", - "version": "==2.7.7" + "version": "==2.7.8" }, "coreapi": { "hashes": [ @@ -80,6 +129,42 @@ ], "version": "==0.0.4" }, + "cryptography": { + "hashes": [ + "sha256:07ca431b788249af92764e3be9a488aa1d39a0bc3be313d826bbec690417e538", + "sha256:13b88a0bd044b4eae1ef40e265d006e34dbcde0c2f1e15eb9896501b2d8f6c6f", + "sha256:32434673d8505b42c0de4de86da8c1620651abd24afe91ae0335597683ed1b77", + "sha256:3cd75a683b15576cfc822c7c5742b3276e50b21a06672dc3a800a2d5da4ecd1b", + "sha256:4e7268a0ca14536fecfdf2b00297d4e407da904718658c1ff1961c713f90fd33", + "sha256:545a8550782dda68f8cdc75a6e3bf252017aa8f75f19f5a9ca940772fc0cb56e", + "sha256:55d0b896631412b6f0c7de56e12eb3e261ac347fbaa5d5e705291a9016e5f8cb", + "sha256:5849d59358547bf789ee7e0d7a9036b2d29e9a4ddf1ce5e06bb45634f995c53e", + "sha256:6dc59630ecce8c1f558277ceb212c751d6730bd12c80ea96b4ac65637c4f55e7", + "sha256:7117319b44ed1842c617d0a452383a5a052ec6aa726dfbaffa8b94c910444297", + "sha256:75e8e6684cf0034f6bf2a97095cb95f81537b12b36a8fedf06e73050bb171c2d", + "sha256:7b8d9d8d3a9bd240f453342981f765346c87ade811519f98664519696f8e6ab7", + "sha256:a035a10686532b0587d58a606004aa20ad895c60c4d029afa245802347fab57b", + "sha256:a4e27ed0b2504195f855b52052eadcc9795c59909c9d84314c5408687f933fc7", + "sha256:a733671100cd26d816eed39507e585c156e4498293a907029969234e5e634bc4", + "sha256:a75f306a16d9f9afebfbedc41c8c2351d8e61e818ba6b4c40815e2b5740bb6b8", + "sha256:bd717aa029217b8ef94a7d21632a3bb5a4e7218a4513d2521c2a2fd63011e98b", + "sha256:d25cecbac20713a7c3bc544372d42d8eafa89799f492a43b79e1dfd650484851", + "sha256:d26a2557d8f9122f9bf445fc7034242f4375bd4e95ecda007667540270965b13", + "sha256:d3545829ab42a66b84a9aaabf216a4dce7f16dbc76eb69be5c302ed6b8f4a29b", + "sha256:d3d5e10be0cf2a12214ddee45c6bd203dab435e3d83b4560c03066eda600bfe3", + "sha256:efe15aca4f64f3a7ea0c09c87826490e50ed166ce67368a68f315ea0807a20df" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", + "version": "==3.2.1" + }, + "defusedxml": { + "hashes": [ + "sha256:6687150770438374ab581bb7a1b327a847dd9c5749e396102de3fad4e8a3ef93", + "sha256:f684034d135af4c6cbb949b8a4d2ed61634515257a67299e5f940fbaa34377f5" + ], + "markers": "python_version >= '3.0'", + "version": "==0.6.0" + }, "dj-database-url": { "hashes": [ "sha256:4aeaeb1f573c74835b0686a2b46b85990571159ffc21aa57ecd4d1e1cb334163", @@ -90,19 +175,27 @@ }, "django": { "hashes": [ - "sha256:62cf45e5ee425c52e411c0742e641a6588b7e8af0d2c274a27940931b2786594", - "sha256:83ced795a0f239f41d8ecabf51cc5fad4b97462a6008dc12e5af3cb9288724ec" + "sha256:558cb27930defd9a6042133258caf797b2d1dee233959f537e3dc475cb49bd7c", + "sha256:cf5370a4d7765a9dd6d42a7b96b53c74f9446cd38209211304b210fe0404b861" ], "index": "pypi", - "version": "==2.2.16" + "version": "==2.2.17" + }, + "django-admin-sso": { + "hashes": [ + "sha256:1f11298f9a0fe7c34acfae057ae8e19637046fef0cd4b5b3fa0fcebb1e5526a9", + "sha256:32548dc797296642d4f7b51d37a48fd2fbfd938e2db01f738c4ef4acc16d1d5e" + ], + "index": "pypi", + "version": "==3.0.0" }, "django-axes": { "hashes": [ - "sha256:0ed24754dddc5358c40794e5afc32e5621cd19f9c51f67944afc6b79aef00e62", - "sha256:6194e72902caffa63ef04e2be2d98b3e184523f946b88556ab428210a61ba6a9" + "sha256:25827cd722b006845e9abd5ecbfba92ebad3935a29258d457158212d4e1f0ded", + "sha256:320ddc577387b1b7926468b9313fd8048f86b70e35a02faa858cbac003900374" ], "index": "pypi", - "version": "==5.8.0" + "version": "==5.9.0" }, "django-cors-headers": { "hashes": [ @@ -130,17 +223,17 @@ }, "django-health-check": { "hashes": [ - "sha256:6e84e7a3e5f1fcb82b7692833fa205bc274415850d333d5a50259de06080dfa8", - "sha256:d5f5cbf3c34bc5ea297696e183c5084b0c15d3bd13d9eb997c25258241589c75" + "sha256:2cb3944e313e435bdf299288e109f398b6c08b610e09cc90d7f5f6a2bcf469fc", + "sha256:8b0835f04ebaeb0d12498a5ef47dd22196237c3987ff28bcce9ed28b5a169d5e" ], "index": "pypi", - "version": "==3.14.3" + "version": "==3.16.1" }, "django-ipware": { "hashes": [ - "sha256:73a640a5bff00aa7503a35e92e462001cfabb07d73d649c262f117423beee953" + "sha256:c7df8e1410a8e5d6b1fbae58728402ea59950f043c3582e033e866f0f0cf5e94" ], - "version": "==3.0.1" + "version": "==3.0.2" }, "django-ordered-model": { "hashes": [ @@ -190,11 +283,10 @@ }, "djangorestframework": { "hashes": [ - "sha256:5c5071fcbad6dce16f566d492015c829ddb0df42965d488b878594aabc3aed21", - "sha256:d54452aedebb4b650254ca092f9f4f5df947cb1de6ab245d817b08b4f4156249" + "sha256:0209bafcb7b5010fdfec784034f059d512256424de2a0f084cb82b096d6dd6a7" ], "index": "pypi", - "version": "==3.12.1" + "version": "==3.12.2" }, "djangorestframework-recursive": { "hashes": [ @@ -204,13 +296,21 @@ "index": "pypi", "version": "==0.1.2" }, + "djangorestframework-simplejwt": { + "hashes": [ + "sha256:7adc913ba0d2ed7f46e0b9bf6e86f9bd9248f1c4201722b732b8213e0ea66f9f", + "sha256:bd587700b6ab34a6c6b12d426cce4fa580d57ef1952ad4ba3b79707784619ed3" + ], + "markers": "python_version >= '3.7'", + "version": "==4.6.0" + }, "djoser": { "hashes": [ - "sha256:5cd98687cdb6d87af632c45fe6268e09c0774b3ba89db02038727ca6e711c64d", - "sha256:6b5f225122d30f3f84a46032d8f98a03412903b225d418d373463275ee1d5c39" + "sha256:3299073aa5822f9ad02bc872b87e719051c07d36cdc87a05b2afdb2c3bad46d1", + "sha256:9590378d59eb3243572bcb6b0a45268a3e31bedddc15235ca248a18c7bc0ffe6" ], "index": "pypi", - "version": "==2.0.5" + "version": "==2.1.0" }, "drf-nested-routers": { "hashes": [ @@ -222,11 +322,11 @@ }, "drf-yasg2": { "hashes": [ - "sha256:65826bf19e5222d38b84380468303c8c389d0b9e2335ee6efa4151ba87ca0a3f", - "sha256:6c662de6e0ffd4f74c49c06a88b8a9d1eb4bc9d7bfe82dac9f80a51a23cacecb" + "sha256:7037a8041eb5d1073fa504a284fc889685f93d0bfd008a963db1b366db786734", + "sha256:75e661ca5cf15eb44fcfab408c7b864f87c20794f564aa08b3a31817a857f19d" ], "index": "pypi", - "version": "==1.19.3" + "version": "==1.19.4" }, "future": { "hashes": [ @@ -245,19 +345,19 @@ }, "google-api-python-client": { "hashes": [ - "sha256:1892cd490d164e5ec2f2168dc3b4fa0af68f36ca15a88b91bca1826b3d4f2829", - "sha256:203d5453660867136aae61922c5d849163fb9be478985fad84e09dee6f605c06" + "sha256:1f5cfcb92c8e3bd0a69cb2ff3cefa5ff16ffa1900af795a53f5bed93c8238951", + "sha256:608552e52ea994a014be8bb0489923328a50776190e0858caaf7b186ebad22bf" ], "index": "pypi", - "version": "==1.12.5" + "version": "==1.12.6" }, "google-auth": { "hashes": [ - "sha256:712dd7d140a9a1ea218e5688c7fcb04af71b431a29ec9ce433e384c60e387b98", - "sha256:9c0f71789438d703f77b94aad4ea545afaec9a65f10e6cc1bc8b89ce242244bb" + "sha256:5176db85f1e7e837a646cd9cede72c3c404ccf2e3373d9ee14b2db88febad440", + "sha256:b728625ff5dfce8f9e56a499c8a4eb51443a67f20f6d28b67d5774c310ec4b6b" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.22.1" + "version": "==1.23.0" }, "google-auth-httplib2": { "hashes": [ @@ -307,11 +407,11 @@ }, "influxdb-client": { "hashes": [ - "sha256:68348df327328c100f93fa3fe38a3837879e73dd5a90bf5d408e601457b7a6ae", - "sha256:aff03a204ba78bfa0df2fd079b7e4549baf24e9f30345cc5552bce057e6c1766" + "sha256:213cece87fbb71411c6d387d65e0cbc3d529bc37c734635e6ee4449443757331", + "sha256:f233da171a2508274d4cebba3ec66d2cc4729229e66419aabeb20220e3b36c82" ], "index": "pypi", - "version": "==1.11.0" + "version": "==1.12.0" }, "itypes": { "hashes": [ @@ -383,6 +483,14 @@ "index": "pypi", "version": "==4.1.3" }, + "oauthlib": { + "hashes": [ + "sha256:bee41cc35fcca6e988463cacc3bcb8a96224f470ca547e697b604cc697b2f889", + "sha256:df884cd6cbe20e32633f1db1072e9356f53638e4361bef4e8b03c9127c9328ea" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==3.1.0" + }, "packaging": { "hashes": [ "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8", @@ -393,62 +501,64 @@ }, "protobuf": { "hashes": [ - "sha256:0bba42f439bf45c0f600c3c5993666fcb88e8441d011fad80a11df6f324eef33", - "sha256:1e834076dfef9e585815757a2c7e4560c7ccc5962b9d09f831214c693a91b463", - "sha256:339c3a003e3c797bc84499fa32e0aac83c768e67b3de4a5d7a5a9aa3b0da634c", - "sha256:361acd76f0ad38c6e38f14d08775514fbd241316cce08deb2ce914c7dfa1184a", - "sha256:3dee442884a18c16d023e52e32dd34a8930a889e511af493f6dc7d4d9bf12e4f", - "sha256:4d1174c9ed303070ad59553f435846a2f877598f59f9afc1b89757bdf846f2a7", - "sha256:5db9d3e12b6ede5e601b8d8684a7f9d90581882925c96acf8495957b4f1b204b", - "sha256:6a82e0c8bb2bf58f606040cc5814e07715b2094caeba281e2e7d0b0e2e397db5", - "sha256:8c35bcbed1c0d29b127c886790e9d37e845ffc2725cc1db4bd06d70f4e8359f4", - "sha256:91c2d897da84c62816e2f473ece60ebfeab024a16c1751aaf31100127ccd93ec", - "sha256:9c2e63c1743cba12737169c447374fab3dfeb18111a460a8c1a000e35836b18c", - "sha256:9edfdc679a3669988ec55a989ff62449f670dfa7018df6ad7f04e8dbacb10630", - "sha256:c0c5ab9c4b1eac0a9b838f1e46038c3175a95b0f2d944385884af72876bd6bc7", - "sha256:c8abd7605185836f6f11f97b21200f8a864f9cb078a193fe3c9e235711d3ff1e", - "sha256:d69697acac76d9f250ab745b46c725edf3e98ac24763990b24d58c16c642947a", - "sha256:df3932e1834a64b46ebc262e951cd82c3cf0fa936a154f0a42231140d8237060", - "sha256:e7662437ca1e0c51b93cadb988f9b353fa6b8013c0385d63a70c8a77d84da5f9", - "sha256:f68eb9d03c7d84bd01c790948320b768de8559761897763731294e3bc316decb" - ], - "version": "==3.13.0" + "sha256:0e247612fadda953047f53301a7b0407cb0c3cb4ae25a6fde661597a04039b3c", + "sha256:0fc96785262042e4863b3f3b5c429d4636f10d90061e1840fce1baaf59b1a836", + "sha256:1c51fda1bbc9634246e7be6016d860be01747354ed7015ebe38acf4452f470d2", + "sha256:1d63eb389347293d8915fb47bee0951c7b5dab522a4a60118b9a18f33e21f8ce", + "sha256:22bcd2e284b3b1d969c12e84dc9b9a71701ec82d8ce975fdda19712e1cfd4e00", + "sha256:2a7e2fe101a7ace75e9327b9c946d247749e564a267b0515cf41dfe450b69bac", + "sha256:43b554b9e73a07ba84ed6cf25db0ff88b1e06be610b37656e292e3cbb5437472", + "sha256:4b74301b30513b1a7494d3055d95c714b560fbb630d8fb9956b6f27992c9f980", + "sha256:4e75105c9dfe13719b7293f75bd53033108f4ba03d44e71db0ec2a0e8401eafd", + "sha256:5b7a637212cc9b2bcf85dd828b1178d19efdf74dbfe1ddf8cd1b8e01fdaaa7f5", + "sha256:5e9806a43232a1fa0c9cf5da8dc06f6910d53e4390be1fa06f06454d888a9142", + "sha256:629b03fd3caae7f815b0c66b41273f6b1900a579e2ccb41ef4493a4f5fb84f3a", + "sha256:72230ed56f026dd664c21d73c5db73ebba50d924d7ba6b7c0d81a121e390406e", + "sha256:86a75477addde4918e9a1904e5c6af8d7b691f2a3f65587d73b16100fbe4c3b2", + "sha256:8971c421dbd7aad930c9bd2694122f332350b6ccb5202a8b7b06f3f1a5c41ed5", + "sha256:9616f0b65a30851e62f1713336c931fcd32c057202b7ff2cfbfca0fc7d5e3043", + "sha256:b0d5d35faeb07e22a1ddf8dce620860c8fe145426c02d1a0ae2688c6e8ede36d", + "sha256:ecc33531a213eee22ad60e0e2aaea6c8ba0021f0cce35dbf0ab03dee6e2a23a1" + ], + "version": "==3.14.0" }, "psycopg2-binary": { "hashes": [ - "sha256:0deac2af1a587ae12836aa07970f5cb91964f05a7c6cdb69d8425ff4c15d4e2c", - "sha256:0e4dc3d5996760104746e6cfcdb519d9d2cd27c738296525d5867ea695774e67", - "sha256:11b9c0ebce097180129e422379b824ae21c8f2a6596b159c7659e2e5a00e1aa0", - "sha256:1fabed9ea2acc4efe4671b92c669a213db744d2af8a9fc5d69a8e9bc14b7a9db", - "sha256:2dac98e85565d5688e8ab7bdea5446674a83a3945a8f416ad0110018d1501b94", - "sha256:42ec1035841b389e8cc3692277a0bd81cdfe0b65d575a2c8862cec7a80e62e52", - "sha256:6a32f3a4cb2f6e1a0b15215f448e8ce2da192fd4ff35084d80d5e39da683e79b", - "sha256:7312e931b90fe14f925729cde58022f5d034241918a5c4f9797cac62f6b3a9dd", - "sha256:7d92a09b788cbb1aec325af5fcba9fed7203897bbd9269d5691bb1e3bce29550", + "sha256:ad20d2eb875aaa1ea6d0f2916949f5c08a19c74d05b16ce6ebf6d24f2c9f75d1", + "sha256:e1f57aa70d3f7cc6947fd88636a481638263ba04a742b4a37dd25c373e41491a", "sha256:833709a5c66ca52f1d21d41865a637223b368c0ee76ea54ca5bad6f2526c7679", - "sha256:89705f45ce07b2dfa806ee84439ec67c5d9a0ef20154e0e475e2b2ed392a5b83", - "sha256:8cd0fb36c7412996859cb4606a35969dd01f4ea34d9812a141cd920c3b18be77", - "sha256:950bc22bb56ee6ff142a2cb9ee980b571dd0912b0334aa3fe0fe3788d860bea2", - "sha256:a0c50db33c32594305b0ef9abc0cb7db13de7621d2cadf8392a1d9b3c437ef77", - "sha256:a0eb43a07386c3f1f1ebb4dc7aafb13f67188eab896e7397aa1ee95a9c884eb2", + "sha256:11b9c0ebce097180129e422379b824ae21c8f2a6596b159c7659e2e5a00e1aa0", "sha256:aaa4213c862f0ef00022751161df35804127b78adf4a2755b9f991a507e425fd", - "sha256:ac0c682111fbf404525dfc0f18a8b5f11be52657d4f96e9fcb75daf4f3984859", - "sha256:ad20d2eb875aaa1ea6d0f2916949f5c08a19c74d05b16ce6ebf6d24f2c9f75d1", - "sha256:b4afc542c0ac0db720cf516dd20c0846f71c248d2b3d21013aa0d4ef9c71ca25", - "sha256:b8a3715b3c4e604bcc94c90a825cd7f5635417453b253499664f784fc4da0152", "sha256:ba28584e6bca48c59eecbf7efb1576ca214b47f05194646b081717fa628dfddf", - "sha256:ba381aec3a5dc29634f20692349d73f2d21f17653bda1decf0b52b11d694541f", + "sha256:ac0c682111fbf404525dfc0f18a8b5f11be52657d4f96e9fcb75daf4f3984859", + "sha256:6422f2ff0919fd720195f64ffd8f924c1395d30f9a495f31e2392c2efafb5056", + "sha256:0deac2af1a587ae12836aa07970f5cb91964f05a7c6cdb69d8425ff4c15d4e2c", "sha256:bd1be66dde2b82f80afb9459fc618216753f67109b859a361cf7def5c7968729", - "sha256:c2507d796fca339c8fb03216364cca68d87e037c1f774977c8fc377627d01c71", - "sha256:cec7e622ebc545dbb4564e483dd20e4e404da17ae07e06f3e780b2dacd5cee66", - "sha256:d14b140a4439d816e3b1229a4a525df917d6ea22a0771a2a78332273fd9528a4", + "sha256:1fabed9ea2acc4efe4671b92c669a213db744d2af8a9fc5d69a8e9bc14b7a9db", "sha256:d1b4ab59e02d9008efe10ceabd0b31e79519da6fb67f7d8e8977118832d0f449", + "sha256:b8a3715b3c4e604bcc94c90a825cd7f5635417453b253499664f784fc4da0152", + "sha256:d14b140a4439d816e3b1229a4a525df917d6ea22a0771a2a78332273fd9528a4", + "sha256:ee69dad2c7155756ad114c02db06002f4cded41132cc51378e57aad79cc8e4f4", + "sha256:7312e931b90fe14f925729cde58022f5d034241918a5c4f9797cac62f6b3a9dd", + "sha256:15978a1fbd225583dd8cdaf37e67ccc278b5abecb4caf6b2d6b8e2b948e953f6", "sha256:d5227b229005a696cc67676e24c214740efd90b148de5733419ac9aaba3773da", - "sha256:e1f57aa70d3f7cc6947fd88636a481638263ba04a742b4a37dd25c373e41491a", + "sha256:a0c50db33c32594305b0ef9abc0cb7db13de7621d2cadf8392a1d9b3c437ef77", "sha256:e74a55f6bad0e7d3968399deb50f61f4db1926acf4a6d83beaaa7df986f48b1c", "sha256:e82aba2188b9ba309fd8e271702bd0d0fc9148ae3150532bbb474f4590039ffb", - "sha256:ee69dad2c7155756ad114c02db06002f4cded41132cc51378e57aad79cc8e4f4", - "sha256:f5ab93a2cb2d8338b1674be43b442a7f544a0971da062a5da774ed40587f18f5" + "sha256:8cd0fb36c7412996859cb4606a35969dd01f4ea34d9812a141cd920c3b18be77", + "sha256:89705f45ce07b2dfa806ee84439ec67c5d9a0ef20154e0e475e2b2ed392a5b83", + "sha256:2dac98e85565d5688e8ab7bdea5446674a83a3945a8f416ad0110018d1501b94", + "sha256:ba381aec3a5dc29634f20692349d73f2d21f17653bda1decf0b52b11d694541f", + "sha256:7d92a09b788cbb1aec325af5fcba9fed7203897bbd9269d5691bb1e3bce29550", + "sha256:c2507d796fca339c8fb03216364cca68d87e037c1f774977c8fc377627d01c71", + "sha256:a0eb43a07386c3f1f1ebb4dc7aafb13f67188eab896e7397aa1ee95a9c884eb2", + "sha256:b4afc542c0ac0db720cf516dd20c0846f71c248d2b3d21013aa0d4ef9c71ca25", + "sha256:f5ab93a2cb2d8338b1674be43b442a7f544a0971da062a5da774ed40587f18f5", + "sha256:950bc22bb56ee6ff142a2cb9ee980b571dd0912b0334aa3fe0fe3788d860bea2", + "sha256:42ec1035841b389e8cc3692277a0bd81cdfe0b65d575a2c8862cec7a80e62e52", + "sha256:6a32f3a4cb2f6e1a0b15215f448e8ce2da192fd4ff35084d80d5e39da683e79b", + "sha256:cec7e622ebc545dbb4564e483dd20e4e404da17ae07e06f3e780b2dacd5cee66", + "sha256:0e4dc3d5996760104746e6cfcdb519d9d2cd27c738296525d5867ea695774e67" ], "index": "pypi", "version": "==2.8.6" @@ -489,6 +599,14 @@ ], "version": "==0.2.8" }, + "pycparser": { + "hashes": [ + "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0", + "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==2.20" + }, "pyjwt": { "hashes": [ "sha256:5c6eca3c2940464d106b99ba83b00c6add741c9becaec087fb7ccdefea71350e", @@ -528,20 +646,36 @@ "index": "pypi", "version": "==3.1.0" }, + "python3-openid": { + "hashes": [ + "sha256:33fbf6928f401e0b790151ed2b5290b02545e8775f982485205a066f874aaeaf", + "sha256:6626f771e0417486701e0b4daff762e7212e820ca5b29fcc0d05f6f8736dfa6b" + ], + "markers": "python_version >= '3.0'", + "version": "==3.2.0" + }, "pytz": { "hashes": [ - "sha256:a494d53b6d39c3c6e44c3bec237336e14305e4f29bbf800b599253057fbb79ed", - "sha256:c35965d010ce31b23eeb663ed3cc8c906275d6be1a34393a1d73a41febf4a048" + "sha256:3e6b7dd2d1e0a59084bcee14a17af60c5c562cdc16d828e8eba2e683d3a7e268", + "sha256:5c55e189b682d420be27c6995ba6edce0c0a77dd67bfbe2ae6607134d5851ffd" ], - "version": "==2020.1" + "version": "==2020.4" }, "requests": { "hashes": [ - "sha256:b3559a131db72c33ee969480840fff4bb6dd111de7dd27c8ee1f820f4f00231b", - "sha256:fe75cc94a9443b9246fc7049224f75604b113c36acb93f87b80ed42c44cbb898" + "sha256:7f1a0b932f4a60a1a65caa4263921bb7d9ee911957e0ae4a23a6dd08185ad5f8", + "sha256:e786fa28d8c9154e6a4de5d46a1d921b8749f8b74e28bde23768e5e16eece998" ], "index": "pypi", - "version": "==2.24.0" + "version": "==2.25.0" + }, + "requests-oauthlib": { + "hashes": [ + "sha256:7f71572defaecd16372f9006f33c2ec8c077c3cfa6f5911a9a90202beb513f3d", + "sha256:b4261601a71fd721a8bd6d7aa1cc1d6a8a93b4a9f5e96626f8e4d91e8beeaa6a", + "sha256:fa6c47b933f01060936d87ae9327fead68768b69c6c9ea2109c48be30f2d4dbc" + ], + "version": "==1.3.0" }, "rsa": { "hashes": [ @@ -640,6 +774,22 @@ ], "version": "==2.4.3" }, + "social-auth-app-django": { + "hashes": [ + "sha256:2c69e57df0b30c9c1823519c5f1992cbe4f3f98fdc7d95c840e091a752708840", + "sha256:567ad0e028311541d7dfed51d3bf2c60440a6fd236d5d4d06c5a618b3d6c57c5", + "sha256:df5212370bd250108987c4748419a1a1d0cec750878856c2644c36aaa0fd3e58" + ], + "version": "==4.0.0" + }, + "social-auth-core": { + "hashes": [ + "sha256:21c0639c56befd33ec162c2210d583bb1de8e1136d53b21bafb96afaf2f86c91", + "sha256:2f6ce1af8ec2b2cc37b86d647f7d4e4292f091ee556941db34b1e0e2dee77fc0", + "sha256:4a3cdf69c449b235cdabd54a1be7ba3722611297e69fded52e3584b1a990af25" + ], + "version": "==3.3.3" + }, "sqlparse": { "hashes": [ "sha256:017cde379adbd6a1f15a61873f43e8274179378e95ef3fede90b5aa64d304ed0", @@ -650,9 +800,9 @@ }, "twilio": { "hashes": [ - "sha256:9d591617b22e75b26cda11a10d353e2001d990a7ca1696d92e50abfc6ecdcb73" + "sha256:effb4d6e9e9a9069065fbe21dea844597376ae6d6333626f14b05ba6b35bbb22" ], - "version": "==6.46.0" + "version": "==6.47.0" }, "uritemplate": { "hashes": [ @@ -664,11 +814,11 @@ }, "urllib3": { "hashes": [ - "sha256:8d7eaa5a82a1cac232164990f04874c594c9453ec55eef02eab885aa02fc17a2", - "sha256:f5321fbe4bf3fefa0efd0bfe7fb14e90909eb62a48ccda331726b4319897dd5e" + "sha256:19188f96923873c92ccb987120ec4acaa12f0461fa9ce5d3d0772bc965a39e08", + "sha256:d8ff90d979214d7b4f8ce956e80f4028fc6860e4431f731ea4a8c08f23f99473" ], "markers": "python_version != '3.4'", - "version": "==1.25.11" + "version": "==1.26.2" }, "whitenoise": { "hashes": [ @@ -705,11 +855,11 @@ }, "attrs": { "hashes": [ - "sha256:26b54ddbbb9ee1d34d5d3668dd37d6cf74990ab23c828c2888dccdceee395594", - "sha256:fce7fc47dfc976152e82d53ff92fa0407700c21acd20886a13777a0d20e655dc" + "sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6", + "sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==20.2.0" + "version": "==20.3.0" }, "autopep8": { "hashes": [ @@ -741,14 +891,6 @@ "index": "pypi", "version": "==1.0.0" }, - "importlib-metadata": { - "hashes": [ - "sha256:77a540690e24b0305878c37ffd421785a6f7e53c8b5720d211b211de8d0e95da", - "sha256:cefa1a2f919b866c5beb7c9f7b0ebb4061f30a8a9bf16d609b000e2dfaceb9c3" - ], - "markers": "python_version < '3.8'", - "version": "==2.0.0" - }, "iniconfig": { "hashes": [ "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3", @@ -815,10 +957,10 @@ }, "pathspec": { "hashes": [ - "sha256:7d91249d21749788d07a2d0f94147accd8f845507400749ea19c1ec9054a12b0", - "sha256:da45173eb3a6f2a5a487efba21f050af2b41948be6ab52b6a1e3ff22bb8b7061" + "sha256:86379d6b86d75816baba717e64b1a3a3469deb93bb76d613c9ce79edc5cb68fd", + "sha256:aa0cb481c4041bf52ffa7b0d8fa6cd3e88a2ca4879c533c9153882ee2556790d" ], - "version": "==0.8.0" + "version": "==0.8.1" }, "pep8": { "hashes": [ @@ -870,11 +1012,11 @@ }, "pytest": { "hashes": [ - "sha256:7a8190790c17d79a11f847fba0b004ee9a8122582ebff4729a082c109e81a4c9", - "sha256:8f593023c1a0f916110285b6efd7f99db07d59546e3d8c36fc60e2ab05d3be92" + "sha256:4288fed0d9153d9646bfcdf0c0428197dba1ecb27a33bb6e031d002fa88653fe", + "sha256:c0a7e94a8cdbc5422a51ccdad8e6f1024795939cc89159a0ae7f0b316ad3823e" ], "index": "pypi", - "version": "==6.1.1" + "version": "==6.1.2" }, "pytest-django": { "hashes": [ @@ -886,35 +1028,49 @@ }, "regex": { "hashes": [ - "sha256:0cb23ed0e327c18fb7eac61ebbb3180ebafed5b9b86ca2e15438201e5903b5dd", - "sha256:1a065e7a6a1b4aa851a0efa1a2579eabc765246b8b3a5fd74000aaa3134b8b4e", - "sha256:1a511470db3aa97432ac8c1bf014fcc6c9fbfd0f4b1313024d342549cf86bcd6", - "sha256:1c447b0d108cddc69036b1b3910fac159f2b51fdeec7f13872e059b7bc932be1", - "sha256:2278453c6a76280b38855a263198961938108ea2333ee145c5168c36b8e2b376", - "sha256:240509721a663836b611fa13ca1843079fc52d0b91ef3f92d9bba8da12e768a0", - "sha256:4e21340c07090ddc8c16deebfd82eb9c9e1ec5e62f57bb86194a2595fd7b46e0", - "sha256:570e916a44a361d4e85f355aacd90e9113319c78ce3c2d098d2ddf9631b34505", - "sha256:59d5c6302d22c16d59611a9fd53556554010db1d47e9df5df37be05007bebe75", - "sha256:6a46eba253cedcbe8a6469f881f014f0a98819d99d341461630885139850e281", - "sha256:6f567df0601e9c7434958143aebea47a9c4b45434ea0ae0286a4ec19e9877169", - "sha256:781906e45ef1d10a0ed9ec8ab83a09b5e0d742de70e627b20d61ccb1b1d3964d", - "sha256:8469377a437dbc31e480993399fd1fd15fe26f382dc04c51c9cb73e42965cc06", - "sha256:8cd0d587aaac74194ad3e68029124c06245acaeddaae14cb45844e5c9bebeea4", - "sha256:97a023f97cddf00831ba04886d1596ef10f59b93df7f855856f037190936e868", - "sha256:a973d5a7a324e2a5230ad7c43f5e1383cac51ef4903bf274936a5634b724b531", - "sha256:af360e62a9790e0a96bc9ac845d87bfa0e4ee0ee68547ae8b5a9c1030517dbef", - "sha256:b706c70070eea03411b1761fff3a2675da28d042a1ab7d0863b3efe1faa125c9", - "sha256:bfd7a9fddd11d116a58b62ee6c502fd24cfe22a4792261f258f886aa41c2a899", - "sha256:c30d8766a055c22e39dd7e1a4f98f6266169f2de05db737efe509c2fb9c8a3c8", - "sha256:c53dc8ee3bb7b7e28ee9feb996a0c999137be6c1d3b02cb6b3c4cba4f9e5ed09", - "sha256:c95d514093b80e5309bdca5dd99e51bcf82c44043b57c34594d9d7556bd04d05", - "sha256:d43cf21df524283daa80ecad551c306b7f52881c8d0fe4e3e76a96b626b6d8d8", - "sha256:d62205f00f461fe8b24ade07499454a3b7adf3def1225e258b994e2215fd15c5", - "sha256:e289a857dca3b35d3615c3a6a438622e20d1bf0abcb82c57d866c8d0be3f44c4", - "sha256:e5f6aa56dda92472e9d6f7b1e6331f4e2d51a67caafff4d4c5121cadac03941e", - "sha256:f4b1c65ee86bfbf7d0c3dfd90592a9e3d6e9ecd36c367c884094c050d4c35d04" - ], - "version": "==2020.10.23" + "sha256:02951b7dacb123d8ea6da44fe45ddd084aa6777d4b2454fa0da61d569c6fa538", + "sha256:0d08e71e70c0237883d0bef12cad5145b84c3705e9c6a588b2a9c7080e5af2a4", + "sha256:1862a9d9194fae76a7aaf0150d5f2a8ec1da89e8b55890b1786b8f88a0f619dc", + "sha256:1ab79fcb02b930de09c76d024d279686ec5d532eb814fd0ed1e0051eb8bd2daa", + "sha256:1fa7ee9c2a0e30405e21031d07d7ba8617bc590d391adfc2b7f1e8b99f46f444", + "sha256:262c6825b309e6485ec2493ffc7e62a13cf13fb2a8b6d212f72bd53ad34118f1", + "sha256:2a11a3e90bd9901d70a5b31d7dd85114755a581a5da3fc996abfefa48aee78af", + "sha256:2c99e97d388cd0a8d30f7c514d67887d8021541b875baf09791a3baad48bb4f8", + "sha256:3128e30d83f2e70b0bed9b2a34e92707d0877e460b402faca908c6667092ada9", + "sha256:38c8fd190db64f513fe4e1baa59fed086ae71fa45083b6936b52d34df8f86a88", + "sha256:3bddc701bdd1efa0d5264d2649588cbfda549b2899dc8d50417e47a82e1387ba", + "sha256:4902e6aa086cbb224241adbc2f06235927d5cdacffb2425c73e6570e8d862364", + "sha256:49cae022fa13f09be91b2c880e58e14b6da5d10639ed45ca69b85faf039f7a4e", + "sha256:56e01daca75eae420bce184edd8bb341c8eebb19dd3bce7266332258f9fb9dd7", + "sha256:5862975b45d451b6db51c2e654990c1820523a5b07100fc6903e9c86575202a0", + "sha256:6a8ce43923c518c24a2579fda49f093f1397dad5d18346211e46f134fc624e31", + "sha256:6c54ce4b5d61a7129bad5c5dc279e222afd00e721bf92f9ef09e4fae28755683", + "sha256:6e4b08c6f8daca7d8f07c8d24e4331ae7953333dbd09c648ed6ebd24db5a10ee", + "sha256:717881211f46de3ab130b58ec0908267961fadc06e44f974466d1887f865bd5b", + "sha256:749078d1eb89484db5f34b4012092ad14b327944ee7f1c4f74d6279a6e4d1884", + "sha256:7913bd25f4ab274ba37bc97ad0e21c31004224ccb02765ad984eef43e04acc6c", + "sha256:7a25fcbeae08f96a754b45bdc050e1fb94b95cab046bf56b016c25e9ab127b3e", + "sha256:83d6b356e116ca119db8e7c6fc2983289d87b27b3fac238cfe5dca529d884562", + "sha256:8b882a78c320478b12ff024e81dc7d43c1462aa4a3341c754ee65d857a521f85", + "sha256:8f6a2229e8ad946e36815f2a03386bb8353d4bde368fdf8ca5f0cb97264d3b5c", + "sha256:9801c4c1d9ae6a70aeb2128e5b4b68c45d4f0af0d1535500884d644fa9b768c6", + "sha256:a15f64ae3a027b64496a71ab1f722355e570c3fac5ba2801cafce846bf5af01d", + "sha256:a3d748383762e56337c39ab35c6ed4deb88df5326f97a38946ddd19028ecce6b", + "sha256:a63f1a07932c9686d2d416fb295ec2c01ab246e89b4d58e5fa468089cab44b70", + "sha256:b2b1a5ddae3677d89b686e5c625fc5547c6e492bd755b520de5332773a8af06b", + "sha256:b2f4007bff007c96a173e24dcda236e5e83bde4358a557f9ccf5e014439eae4b", + "sha256:baf378ba6151f6e272824b86a774326f692bc2ef4cc5ce8d5bc76e38c813a55f", + "sha256:bafb01b4688833e099d79e7efd23f99172f501a15c44f21ea2118681473fdba0", + "sha256:bba349276b126947b014e50ab3316c027cac1495992f10e5682dc677b3dfa0c5", + "sha256:c084582d4215593f2f1d28b65d2a2f3aceff8342aa85afd7be23a9cad74a0de5", + "sha256:d1ebb090a426db66dd80df8ca85adc4abfcbad8a7c2e9a5ec7513ede522e0a8f", + "sha256:d2d8ce12b7c12c87e41123997ebaf1a5767a5be3ec545f64675388970f415e2e", + "sha256:e32f5f3d1b1c663af7f9c4c1e72e6ffe9a78c03a31e149259f531e0fed826512", + "sha256:e3faaf10a0d1e8e23a9b51d1900b72e1635c2d5b0e1bea1c18022486a8e2e52d", + "sha256:f7d29a6fc4760300f86ae329e3b6ca28ea9c20823df123a2ea8693e967b29917", + "sha256:f8f295db00ef5f8bae530fc39af0b40486ca6068733fb860b42115052206466f" + ], + "version": "==2020.11.13" }, "six": { "hashes": [ @@ -926,36 +1082,45 @@ }, "toml": { "hashes": [ - "sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f", - "sha256:bda89d5935c2eac546d648028b9901107a595863cb36bae0c73ac804a9b4ce88" + "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", + "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f" ], - "version": "==0.10.1" + "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==0.10.2" }, "typed-ast": { "hashes": [ "sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355", "sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919", + "sha256:0d8110d78a5736e16e26213114a38ca35cb15b6515d535413b090bd50951556d", "sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa", "sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652", "sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75", + "sha256:3742b32cf1c6ef124d57f95be609c473d7ec4c14d0090e5a5e05a15269fb4d0c", "sha256:4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01", "sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d", "sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1", "sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907", "sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c", "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3", + "sha256:7e4c9d7658aaa1fc80018593abdf8598bf91325af6af5cce4ce7c73bc45ea53d", "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b", "sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614", + "sha256:92c325624e304ebf0e025d1224b77dd4e6393f18aab8d829b5b7e04afe9b7a2c", "sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb", + "sha256:b52ccf7cfe4ce2a1064b18594381bccf4179c2ecf7f513134ec2f993dd4ab395", "sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b", "sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41", "sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6", "sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34", "sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe", + "sha256:d648b8e3bf2fe648745c8ffcee3db3ff903d0817a01a12dd6a6ea7a8f4889072", + "sha256:f208eb7aff048f6bea9586e61af041ddf7f9ade7caed625742af423f6bae3298", + "sha256:fac11badff8313e23717f3dada86a15389d0708275bddf766cca67a84ead3e91", "sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4", + "sha256:fcf135e17cc74dbfbc05894ebca928ffeb23d9790b3167a674921db19082401f", "sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7" ], - "markers": "python_version < '3.8' and implementation_name == 'cpython'", "version": "==1.4.1" }, "typing-extensions": { @@ -971,14 +1136,6 @@ "sha256:b62ffa81fb85f4332a4f609cab4ac40709470da05643a082ec1eb88e6d9b97d7" ], "version": "==1.12.1" - }, - "zipp": { - "hashes": [ - "sha256:16522f69653f0d67be90e8baa4a46d66389145b734345d68a257da53df670903", - "sha256:c1532a8030c32fd52ff6a288d855fe7adef5823ba1d26a29a68fd6314aa72baa" - ], - "markers": "python_version >= '3.6'", - "version": "==3.3.1" } } } From 579c82f7caa0bfa224f8cd7b97c4768314ce65a1 Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Wed, 18 Nov 2020 13:15:02 +0000 Subject: [PATCH 09/47] Replace pipenv with requirements.txt and pin requirements --- .gitlab-ci.yml | 12 +- Pipfile | 60 --- Pipfile.lock | 984 ------------------------------------------- readme.md | 18 +- requirements-dev.txt | 9 + requirements.txt | 33 ++ 6 files changed, 55 insertions(+), 1061 deletions(-) delete mode 100644 Pipfile delete mode 100644 Pipfile.lock create mode 100644 requirements-dev.txt create mode 100644 requirements.txt diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 5cf078b971c2..8232d437bc4b 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -4,7 +4,7 @@ stages: - deploy-aws test: - image: kennethreitz/pipenv + image: python:3.7 stage: test services: - postgres:10.9 @@ -14,8 +14,8 @@ test: POSTGRES_USER: testuser POSTGRES_PASSWORD: testpass script: - - pipenv install --dev - - pipenv run test + - pip install -r requirements-dev.txt + - pytest src -p no:warnings deploydevelop: image: ilyasemenov/gitlab-ci-git-push @@ -25,7 +25,7 @@ deploydevelop: - develop deployawsstaging: - image: bullettrain/elasticbeanstalk-pipenv + image: bullettrain/elasticbeanstalk-pipenv # TODO: remove pipenv from this docker image stage: deploy-aws script: - export AWS_ACCESS_KEY_ID=$AWS_STAGING_ACCESS_KEY_ID @@ -34,7 +34,7 @@ deployawsstaging: - export GOOGLE_ANALYTICS_CLIENT_ID=$GOOGLE_ANALYTICS_CLIENT_ID_STAGING - export GOOGLE_ANALYTICS_KEY=$GOOGLE_ANALYTICS_KEY_STAGING - export DJANGO_SETTINGS_MODULE=$DJANGO_SETTINGS_MODULE_STAGING - - pipenv lock --requirements > ./src/requirements.txt + - cp requirements.txt ./src/requirements.txt - sh generate.sh - git config --global user.email "build@gitlab.com" - git config --global user.name "Gitlab" @@ -52,7 +52,7 @@ deployawsmaster: - export GOOGLE_ANALYTICS_CLIENT_ID=$GOOGLE_ANALYTICS_CLIENT_ID_PRODUCTION - export GOOGLE_ANALYTICS_KEY=$GOOGLE_ANALYTICS_KEY_PRODUCTION - export DJANGO_SETTINGS_MODULE=$DJANGO_SETTINGS_MODULE_PRODUCTION - - pipenv lock --requirements > ./src/requirements.txt + - cp requirements.txt ./src/requirements.txt - sh generate.sh - git config --global user.email "build@gitlab.com" - git config --global user.name "Gitlab" diff --git a/Pipfile b/Pipfile deleted file mode 100644 index 1cc00cadd5ef..000000000000 --- a/Pipfile +++ /dev/null @@ -1,60 +0,0 @@ -[[source]] -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" -test = "pytest src -p no:warnings" -shell = "src/manage.py shell" - -[dev-packages] -pylint = "*" -"pep8" = "*" -"autopep8" = "*" -pytest = "*" -pytest-django = "*" -django-test-migrations = "*" -black = "*" - -[packages] -appdirs = "*" -django-cors-headers = "*" -djangorestframework = "*" -gunicorn = "*" -pyparsing = "*" -requests = "*" -six = "*" -whitenoise = "<4.0" -dj-database-url = "*" -drf-nested-routers = "*" -shortuuid = "*" -sendgrid-django = "*" -psycopg2-binary = "*" -coreapi = "*" -Django = "<3.0" -django-simple-history = "*" -django-debug-toolbar = "*" -google-api-python-client = "*" -"oauth2client" = "*" -djangorestframework-recursive = "*" -packaging = "*" -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 = "*" -django-environ = "*" -django-trench = "*" -djoser = "*" -influxdb-client = "*" -django-ordered-model = "*" -django-ses = "*" -django-axes = "*" -django-admin-sso = "*" - -drf-yasg2 = "*" - -[pipenv] -allow_prereleases = true diff --git a/Pipfile.lock b/Pipfile.lock deleted file mode 100644 index e99a6ae02c03..000000000000 --- a/Pipfile.lock +++ /dev/null @@ -1,984 +0,0 @@ -{ - "_meta": { - "hash": { - "sha256": "4b1b7b71f8ada3fb50f375e931eb912c943df041b9f5ddb49a6b2504c59173ce" - }, - "pipfile-spec": 6, - "requires": {}, - "sources": [ - { - "name": "pypi", - "url": "https://pypi.org/simple", - "verify_ssl": true - } - ] - }, - "default": { - "appdirs": { - "hashes": [ - "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41", - "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128" - ], - "index": "pypi", - "version": "==1.4.4" - }, - "boto3": { - "hashes": [ - "sha256:270ac22a66ce3313e908946193df6e0fb3e81cdf60f5113d62da1d8991b75030", - "sha256:e2857738affb394bbe96473de2ed01331685d6e313bb1a3328fd5f47841429cc" - ], - "version": "==1.16.3" - }, - "botocore": { - "hashes": [ - "sha256:4ea4c74d244c1b4701387fd1abe6a5e1833dc621c6d39f8888f0bfa95ddd82f5", - "sha256:f5084376a8519332a200737f5cd80e87f47868b7da4d57fc192397670e0af022" - ], - "version": "==1.19.3" - }, - "cachetools": { - "hashes": [ - "sha256:513d4ff98dd27f85743a8dc0e92f55ddb1b49e060c2d5961512855cda2c01a98", - "sha256:bbaa39c3dede00175df2dc2b03d0cf18dd2d32a7de7beb68072d13043c9edb20" - ], - "markers": "python_version ~= '3.5'", - "version": "==4.1.1" - }, - "certifi": { - "hashes": [ - "sha256:5930595817496dd21bb8dc35dad090f1c2cd0adfaf21204bf6732ca5d8ee34d3", - "sha256:8fc0819f1f30ba15bdb34cceffb9ef04d99f420f68eb75d901e9560b8749fc41" - ], - "version": "==2020.6.20" - }, - "chardet": { - "hashes": [ - "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", - "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" - ], - "version": "==3.0.4" - }, - "chargebee": { - "hashes": [ - "sha256:1886d6d0dffae085a665f9a000e6faa0695c6669dbfd5c7e2fa12f526c75b563" - ], - "index": "pypi", - "version": "==2.7.7" - }, - "coreapi": { - "hashes": [ - "sha256:46145fcc1f7017c076a2ef684969b641d18a2991051fddec9458ad3f78ffc1cb", - "sha256:bf39d118d6d3e171f10df9ede5666f63ad80bba9a29a8ec17726a66cf52ee6f3" - ], - "index": "pypi", - "version": "==2.3.3" - }, - "coreschema": { - "hashes": [ - "sha256:5e6ef7bf38c1525d5e55a895934ab4273548629f16aed5c0a6caa74ebf45551f", - "sha256:9503506007d482ab0867ba14724b93c18a33b22b6d19fb419ef2d239dd4a1607" - ], - "version": "==0.0.4" - }, - "dj-database-url": { - "hashes": [ - "sha256:4aeaeb1f573c74835b0686a2b46b85990571159ffc21aa57ecd4d1e1cb334163", - "sha256:851785365761ebe4994a921b433062309eb882fedd318e1b0fcecc607ed02da9" - ], - "index": "pypi", - "version": "==0.5.0" - }, - "django": { - "hashes": [ - "sha256:62cf45e5ee425c52e411c0742e641a6588b7e8af0d2c274a27940931b2786594", - "sha256:83ced795a0f239f41d8ecabf51cc5fad4b97462a6008dc12e5af3cb9288724ec" - ], - "index": "pypi", - "version": "==2.2.16" - }, - "django-axes": { - "hashes": [ - "sha256:0ed24754dddc5358c40794e5afc32e5621cd19f9c51f67944afc6b79aef00e62", - "sha256:6194e72902caffa63ef04e2be2d98b3e184523f946b88556ab428210a61ba6a9" - ], - "index": "pypi", - "version": "==5.8.0" - }, - "django-cors-headers": { - "hashes": [ - "sha256:9322255c296d5f75089571f29e520c83ff9693df17aa3cf9f6a4bea7c6740169", - "sha256:db82b2840f667d47872ae3e4a4e0a0d72fbecb42779b8aa233fa8bb965f7836a" - ], - "index": "pypi", - "version": "==3.5.0" - }, - "django-debug-toolbar": { - "hashes": [ - "sha256:a1ce0665f7ef47d27b8df4b5d1058748e1f08500a01421a30d35164f38aaaf4c", - "sha256:c97921a9cd421d392e7860dc4b464db8e06c8628df4dc58fedab012888c293c6" - ], - "index": "pypi", - "version": "==3.1.1" - }, - "django-environ": { - "hashes": [ - "sha256:6c9d87660142608f63ec7d5ce5564c49b603ea8ff25da595fd6098f6dc82afde", - "sha256:c57b3c11ec1f319d9474e3e5a79134f40174b17c7cc024bbb2fad84646b120c4" - ], - "index": "pypi", - "version": "==0.4.5" - }, - "django-health-check": { - "hashes": [ - "sha256:6e84e7a3e5f1fcb82b7692833fa205bc274415850d333d5a50259de06080dfa8", - "sha256:d5f5cbf3c34bc5ea297696e183c5084b0c15d3bd13d9eb997c25258241589c75" - ], - "index": "pypi", - "version": "==3.14.3" - }, - "django-ipware": { - "hashes": [ - "sha256:73a640a5bff00aa7503a35e92e462001cfabb07d73d649c262f117423beee953" - ], - "version": "==3.0.1" - }, - "django-ordered-model": { - "hashes": [ - "sha256:29af6624cf3505daaf0df00e2df1d0726dd777b95e08f304d5ad0264092aa934", - "sha256:d867166ed4dd12501139e119cbbc5b4d19798a3e72740aef0af4879ba97102cf" - ], - "index": "pypi", - "version": "==3.4.1" - }, - "django-ses": { - "hashes": [ - "sha256:45f041acb9f2f8df85f3fddd63b04f1d381982bdc7730a3dc1f88e5795758bdd", - "sha256:9c0a3e59e1e2424093820fa7cd519aa35f9ba978c26917a97a30c682449f0df6" - ], - "index": "pypi", - "version": "==1.0.3" - }, - "django-simple-history": { - "hashes": [ - "sha256:3b7bf6bfbcf973afca123c5786c72b917ed4d92d7bf3b6cb70fe2e3850e763a3", - "sha256:e7e830cb7a768dc90d6ba0507f8023f889bcb62fe31a08f18fac102c55eec539" - ], - "index": "pypi", - "version": "==2.12.0" - }, - "django-storages": { - "hashes": [ - "sha256:12de8fb2605b9b57bfaf54b075280d7cbb3b3ee1ca4bc9b9add147af87fe3a2c", - "sha256:652275ab7844538c462b62810276c0244866f345878256a9e0e86f5b1283ae18" - ], - "index": "pypi", - "version": "==1.10.1" - }, - "django-templated-mail": { - "hashes": [ - "sha256:8db807effebb42a532622e2d142dfd453dafcd0d7794c4c3332acb90656315f9", - "sha256:f7127e1e31d7cad4e6c4b4801d25814d4b8782627ead76f4a75b3b7650687556" - ], - "version": "==1.1.1" - }, - "django-trench": { - "hashes": [ - "sha256:63e189a057c45198d178ea79337e690250b484fcd8ff2057c9fd4b3699639853" - ], - "index": "pypi", - "version": "==0.2.3" - }, - "djangorestframework": { - "hashes": [ - "sha256:5c5071fcbad6dce16f566d492015c829ddb0df42965d488b878594aabc3aed21", - "sha256:d54452aedebb4b650254ca092f9f4f5df947cb1de6ab245d817b08b4f4156249" - ], - "index": "pypi", - "version": "==3.12.1" - }, - "djangorestframework-recursive": { - "hashes": [ - "sha256:e4e51b26b7ee3c9f9b838885d638b91293e7c66e85b5955f278a6e10eb34ce7c", - "sha256:f8fc2d677ccb32fe53ec4153a45f66c822d0ce444824cba56edc76ca89b704ae" - ], - "index": "pypi", - "version": "==0.1.2" - }, - "djoser": { - "hashes": [ - "sha256:5cd98687cdb6d87af632c45fe6268e09c0774b3ba89db02038727ca6e711c64d", - "sha256:6b5f225122d30f3f84a46032d8f98a03412903b225d418d373463275ee1d5c39" - ], - "index": "pypi", - "version": "==2.0.5" - }, - "drf-nested-routers": { - "hashes": [ - "sha256:52be428b046078ed21e9137167035cc2eb8daad036900fd2235ce026306e143a", - "sha256:e043fc937f94ac462a92d2d9fc9a7e55710a67164b558442adfe9634fc519c3b" - ], - "index": "pypi", - "version": "==0.92.1" - }, - "drf-yasg2": { - "hashes": [ - "sha256:65826bf19e5222d38b84380468303c8c389d0b9e2335ee6efa4151ba87ca0a3f", - "sha256:6c662de6e0ffd4f74c49c06a88b8a9d1eb4bc9d7bfe82dac9f80a51a23cacecb" - ], - "index": "pypi", - "version": "==1.19.3" - }, - "future": { - "hashes": [ - "sha256:b1bead90b70cf6ec3f0710ae53a525360fa360d306a86583adc6bf83a4db537d" - ], - "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==0.18.2" - }, - "google-api-core": { - "hashes": [ - "sha256:1bb3c485c38eacded8d685b1759968f6cf47dd9432922d34edb90359eaa391e2", - "sha256:94d8c707d358d8d9e8b0045c42be20efb58433d308bd92cf748511c7825569c8" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.23.0" - }, - "google-api-python-client": { - "hashes": [ - "sha256:1892cd490d164e5ec2f2168dc3b4fa0af68f36ca15a88b91bca1826b3d4f2829", - "sha256:203d5453660867136aae61922c5d849163fb9be478985fad84e09dee6f605c06" - ], - "index": "pypi", - "version": "==1.12.5" - }, - "google-auth": { - "hashes": [ - "sha256:712dd7d140a9a1ea218e5688c7fcb04af71b431a29ec9ce433e384c60e387b98", - "sha256:9c0f71789438d703f77b94aad4ea545afaec9a65f10e6cc1bc8b89ce242244bb" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.22.1" - }, - "google-auth-httplib2": { - "hashes": [ - "sha256:8d092cc60fb16517b12057ec0bba9185a96e3b7169d86ae12eae98e645b7bc39", - "sha256:aeaff501738b289717fac1980db9711d77908a6c227f60e4aa1923410b43e2ee" - ], - "version": "==0.0.4" - }, - "googleapis-common-protos": { - "hashes": [ - "sha256:560716c807117394da12cecb0a54da5a451b5cf9866f1d37e9a5e2329a665351", - "sha256:c8961760f5aad9a711d37b675be103e0cc4e9a39327e0d6d857872f698403e24" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.52.0" - }, - "gunicorn": { - "hashes": [ - "sha256:1904bb2b8a43658807108d59c3f3d56c2b6121a701161de0ddf9ad140073c626", - "sha256:cd4a810dd51bf497552cf3f863b575dabd73d6ad6a91075b65936b151cbf4f9c" - ], - "index": "pypi", - "version": "==20.0.4" - }, - "httplib2": { - "hashes": [ - "sha256:8af66c1c52c7ffe1aa5dc4bcd7c769885254b0756e6e69f953c7f0ab49a70ba3", - "sha256:ca2914b015b6247791c4866782fa6042f495b94401a0f0bd3e1d6e0ba2236782" - ], - "version": "==0.18.1" - }, - "idna": { - "hashes": [ - "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6", - "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==2.10" - }, - "inflection": { - "hashes": [ - "sha256:1a29730d366e996aaacffb2f1f1cb9593dc38e2ddd30c91250c6dde09ea9b417", - "sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2" - ], - "markers": "python_version >= '3.5'", - "version": "==0.5.1" - }, - "influxdb-client": { - "hashes": [ - "sha256:68348df327328c100f93fa3fe38a3837879e73dd5a90bf5d408e601457b7a6ae", - "sha256:aff03a204ba78bfa0df2fd079b7e4549baf24e9f30345cc5552bce057e6c1766" - ], - "index": "pypi", - "version": "==1.11.0" - }, - "itypes": { - "hashes": [ - "sha256:03da6872ca89d29aef62773672b2d408f490f80db48b23079a4b194c86dd04c6", - "sha256:af886f129dea4a2a1e3d36595a2d139589e4dd287f5cab0b40e799ee81570ff1" - ], - "version": "==1.2.0" - }, - "jinja2": { - "hashes": [ - "sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0", - "sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", - "version": "==2.11.2" - }, - "jmespath": { - "hashes": [ - "sha256:b85d0567b8666149a93172712e68920734333c0ce7e89b78b3e987f71e5ed4f9", - "sha256:cdf6525904cc597730141d61b36f2e4b8ecc257c420fa2f4549bac2c2d0cb72f" - ], - "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==0.10.0" - }, - "markupsafe": { - "hashes": [ - "sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473", - "sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161", - "sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235", - "sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5", - "sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42", - "sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff", - "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b", - "sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1", - "sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e", - "sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183", - "sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66", - "sha256:596510de112c685489095da617b5bcbbac7dd6384aeebeda4df6025d0256a81b", - "sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1", - "sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15", - "sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1", - "sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e", - "sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b", - "sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905", - "sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735", - "sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d", - "sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e", - "sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d", - "sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c", - "sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21", - "sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2", - "sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5", - "sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b", - "sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6", - "sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f", - "sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f", - "sha256:cdb132fc825c38e1aeec2c8aa9338310d29d337bebbd7baa06889d09a60a1fa2", - "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7", - "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.1.1" - }, - "oauth2client": { - "hashes": [ - "sha256:b8a81cc5d60e2d364f0b1b98f958dbd472887acaf1a5b05e21c28c31a2d6d3ac", - "sha256:d486741e451287f69568a4d26d70d9acd73a2bbfa275746c535b4209891cccc6" - ], - "index": "pypi", - "version": "==4.1.3" - }, - "packaging": { - "hashes": [ - "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8", - "sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181" - ], - "index": "pypi", - "version": "==20.4" - }, - "protobuf": { - "hashes": [ - "sha256:0bba42f439bf45c0f600c3c5993666fcb88e8441d011fad80a11df6f324eef33", - "sha256:1e834076dfef9e585815757a2c7e4560c7ccc5962b9d09f831214c693a91b463", - "sha256:339c3a003e3c797bc84499fa32e0aac83c768e67b3de4a5d7a5a9aa3b0da634c", - "sha256:361acd76f0ad38c6e38f14d08775514fbd241316cce08deb2ce914c7dfa1184a", - "sha256:3dee442884a18c16d023e52e32dd34a8930a889e511af493f6dc7d4d9bf12e4f", - "sha256:4d1174c9ed303070ad59553f435846a2f877598f59f9afc1b89757bdf846f2a7", - "sha256:5db9d3e12b6ede5e601b8d8684a7f9d90581882925c96acf8495957b4f1b204b", - "sha256:6a82e0c8bb2bf58f606040cc5814e07715b2094caeba281e2e7d0b0e2e397db5", - "sha256:8c35bcbed1c0d29b127c886790e9d37e845ffc2725cc1db4bd06d70f4e8359f4", - "sha256:91c2d897da84c62816e2f473ece60ebfeab024a16c1751aaf31100127ccd93ec", - "sha256:9c2e63c1743cba12737169c447374fab3dfeb18111a460a8c1a000e35836b18c", - "sha256:9edfdc679a3669988ec55a989ff62449f670dfa7018df6ad7f04e8dbacb10630", - "sha256:c0c5ab9c4b1eac0a9b838f1e46038c3175a95b0f2d944385884af72876bd6bc7", - "sha256:c8abd7605185836f6f11f97b21200f8a864f9cb078a193fe3c9e235711d3ff1e", - "sha256:d69697acac76d9f250ab745b46c725edf3e98ac24763990b24d58c16c642947a", - "sha256:df3932e1834a64b46ebc262e951cd82c3cf0fa936a154f0a42231140d8237060", - "sha256:e7662437ca1e0c51b93cadb988f9b353fa6b8013c0385d63a70c8a77d84da5f9", - "sha256:f68eb9d03c7d84bd01c790948320b768de8559761897763731294e3bc316decb" - ], - "version": "==3.13.0" - }, - "psycopg2-binary": { - "hashes": [ - "sha256:0deac2af1a587ae12836aa07970f5cb91964f05a7c6cdb69d8425ff4c15d4e2c", - "sha256:0e4dc3d5996760104746e6cfcdb519d9d2cd27c738296525d5867ea695774e67", - "sha256:11b9c0ebce097180129e422379b824ae21c8f2a6596b159c7659e2e5a00e1aa0", - "sha256:1fabed9ea2acc4efe4671b92c669a213db744d2af8a9fc5d69a8e9bc14b7a9db", - "sha256:2dac98e85565d5688e8ab7bdea5446674a83a3945a8f416ad0110018d1501b94", - "sha256:42ec1035841b389e8cc3692277a0bd81cdfe0b65d575a2c8862cec7a80e62e52", - "sha256:6a32f3a4cb2f6e1a0b15215f448e8ce2da192fd4ff35084d80d5e39da683e79b", - "sha256:7312e931b90fe14f925729cde58022f5d034241918a5c4f9797cac62f6b3a9dd", - "sha256:7d92a09b788cbb1aec325af5fcba9fed7203897bbd9269d5691bb1e3bce29550", - "sha256:833709a5c66ca52f1d21d41865a637223b368c0ee76ea54ca5bad6f2526c7679", - "sha256:89705f45ce07b2dfa806ee84439ec67c5d9a0ef20154e0e475e2b2ed392a5b83", - "sha256:8cd0fb36c7412996859cb4606a35969dd01f4ea34d9812a141cd920c3b18be77", - "sha256:950bc22bb56ee6ff142a2cb9ee980b571dd0912b0334aa3fe0fe3788d860bea2", - "sha256:a0c50db33c32594305b0ef9abc0cb7db13de7621d2cadf8392a1d9b3c437ef77", - "sha256:a0eb43a07386c3f1f1ebb4dc7aafb13f67188eab896e7397aa1ee95a9c884eb2", - "sha256:aaa4213c862f0ef00022751161df35804127b78adf4a2755b9f991a507e425fd", - "sha256:ac0c682111fbf404525dfc0f18a8b5f11be52657d4f96e9fcb75daf4f3984859", - "sha256:ad20d2eb875aaa1ea6d0f2916949f5c08a19c74d05b16ce6ebf6d24f2c9f75d1", - "sha256:b4afc542c0ac0db720cf516dd20c0846f71c248d2b3d21013aa0d4ef9c71ca25", - "sha256:b8a3715b3c4e604bcc94c90a825cd7f5635417453b253499664f784fc4da0152", - "sha256:ba28584e6bca48c59eecbf7efb1576ca214b47f05194646b081717fa628dfddf", - "sha256:ba381aec3a5dc29634f20692349d73f2d21f17653bda1decf0b52b11d694541f", - "sha256:bd1be66dde2b82f80afb9459fc618216753f67109b859a361cf7def5c7968729", - "sha256:c2507d796fca339c8fb03216364cca68d87e037c1f774977c8fc377627d01c71", - "sha256:cec7e622ebc545dbb4564e483dd20e4e404da17ae07e06f3e780b2dacd5cee66", - "sha256:d14b140a4439d816e3b1229a4a525df917d6ea22a0771a2a78332273fd9528a4", - "sha256:d1b4ab59e02d9008efe10ceabd0b31e79519da6fb67f7d8e8977118832d0f449", - "sha256:d5227b229005a696cc67676e24c214740efd90b148de5733419ac9aaba3773da", - "sha256:e1f57aa70d3f7cc6947fd88636a481638263ba04a742b4a37dd25c373e41491a", - "sha256:e74a55f6bad0e7d3968399deb50f61f4db1926acf4a6d83beaaa7df986f48b1c", - "sha256:e82aba2188b9ba309fd8e271702bd0d0fc9148ae3150532bbb474f4590039ffb", - "sha256:ee69dad2c7155756ad114c02db06002f4cded41132cc51378e57aad79cc8e4f4", - "sha256:f5ab93a2cb2d8338b1674be43b442a7f544a0971da062a5da774ed40587f18f5" - ], - "index": "pypi", - "version": "==2.8.6" - }, - "pyasn1": { - "hashes": [ - "sha256:014c0e9976956a08139dc0712ae195324a75e142284d5f87f1a87ee1b068a359", - "sha256:03840c999ba71680a131cfaee6fab142e1ed9bbd9c693e285cc6aca0d555e576", - "sha256:0458773cfe65b153891ac249bcf1b5f8f320b7c2ce462151f8fa74de8934becf", - "sha256:08c3c53b75eaa48d71cf8c710312316392ed40899cb34710d092e96745a358b7", - "sha256:39c7e2ec30515947ff4e87fb6f456dfc6e84857d34be479c9d4a4ba4bf46aa5d", - "sha256:5c9414dcfede6e441f7e8f81b43b34e834731003427e5b09e4e00e3172a10f00", - "sha256:6e7545f1a61025a4e58bb336952c5061697da694db1cae97b116e9c46abcf7c8", - "sha256:78fa6da68ed2727915c4767bb386ab32cdba863caa7dbe473eaae45f9959da86", - "sha256:7ab8a544af125fb704feadb008c99a88805126fb525280b2270bb25cc1d78a12", - "sha256:99fcc3c8d804d1bc6d9a099921e39d827026409a58f2a720dcdb89374ea0c776", - "sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba", - "sha256:e89bf84b5437b532b0803ba5c9a5e054d21fec423a89952a74f87fa2c9b7bce2", - "sha256:fec3e9d8e36808a28efb59b489e4528c10ad0f480e57dcc32b4de5c9d8c9fdf3" - ], - "version": "==0.4.8" - }, - "pyasn1-modules": { - "hashes": [ - "sha256:0845a5582f6a02bb3e1bde9ecfc4bfcae6ec3210dd270522fee602365430c3f8", - "sha256:0fe1b68d1e486a1ed5473f1302bd991c1611d319bba158e98b106ff86e1d7199", - "sha256:15b7c67fabc7fc240d87fb9aabf999cf82311a6d6fb2c70d00d3d0604878c811", - "sha256:426edb7a5e8879f1ec54a1864f16b882c2837bfd06eee62f2c982315ee2473ed", - "sha256:65cebbaffc913f4fe9e4808735c95ea22d7a7775646ab690518c056784bc21b4", - "sha256:905f84c712230b2c592c19470d3ca8d552de726050d1d1716282a1f6146be65e", - "sha256:a50b808ffeb97cb3601dd25981f6b016cbb3d31fbf57a8b8a87428e6158d0c74", - "sha256:a99324196732f53093a84c4369c996713eb8c89d360a496b599fb1a9c47fc3eb", - "sha256:b80486a6c77252ea3a3e9b1e360bc9cf28eaac41263d173c032581ad2f20fe45", - "sha256:c29a5e5cc7a3f05926aff34e097e84f8589cd790ce0ed41b67aed6857b26aafd", - "sha256:cbac4bc38d117f2a49aeedec4407d23e8866ea4ac27ff2cf7fb3e5b570df19e0", - "sha256:f39edd8c4ecaa4556e989147ebf219227e2cd2e8a43c7e7fcb1f1c18c5fd6a3d", - "sha256:fe0644d9ab041506b62782e92b06b8c68cca799e1a9636ec398675459e031405" - ], - "version": "==0.2.8" - }, - "pyjwt": { - "hashes": [ - "sha256:5c6eca3c2940464d106b99ba83b00c6add741c9becaec087fb7ccdefea71350e", - "sha256:8d59a976fb773f3e6a39c85636357c4f0e242707394cadadd9814f5cbaa20e96" - ], - "version": "==1.7.1" - }, - "pyotp": { - "hashes": [ - "sha256:038a3f70b34eaad3f72459e8b411662ef8dfcdd95f7d9203fa489e987a75584b", - "sha256:ef07c393660529261e66902e788b32e46260d2c29eb740978df778260a1c2b4c" - ], - "version": "==2.4.1" - }, - "pyparsing": { - "hashes": [ - "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", - "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" - ], - "index": "pypi", - "version": "==2.4.7" - }, - "python-dateutil": { - "hashes": [ - "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c", - "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==2.8.1" - }, - "python-http-client": { - "hashes": [ - "sha256:0a5855902cede46775912d418a23f05fe6f5d60371df1084bef8c219218ce8d9", - "sha256:7e430f4b9dd2b621b0051f6a362f103447ea8e267594c602a5c502a0c694ee38", - "sha256:84267d8dcb7bcdf4c5cef321a533cc584c5b52159d4a4d3d4139bfed347b8006" - ], - "index": "pypi", - "version": "==3.1.0" - }, - "pytz": { - "hashes": [ - "sha256:a494d53b6d39c3c6e44c3bec237336e14305e4f29bbf800b599253057fbb79ed", - "sha256:c35965d010ce31b23eeb663ed3cc8c906275d6be1a34393a1d73a41febf4a048" - ], - "version": "==2020.1" - }, - "requests": { - "hashes": [ - "sha256:b3559a131db72c33ee969480840fff4bb6dd111de7dd27c8ee1f820f4f00231b", - "sha256:fe75cc94a9443b9246fc7049224f75604b113c36acb93f87b80ed42c44cbb898" - ], - "index": "pypi", - "version": "==2.24.0" - }, - "rsa": { - "hashes": [ - "sha256:109ea5a66744dd859bf16fe904b8d8b627adafb9408753161e766a92e7d681fa", - "sha256:6166864e23d6b5195a5cfed6cd9fed0fe774e226d8f854fcb23b7bbef0350233" - ], - "markers": "python_version >= '3.5'", - "version": "==4.6" - }, - "ruamel.yaml": { - "hashes": [ - "sha256:012b9470a0ea06e4e44e99e7920277edf6b46eee0232a04487ea73a7386340a5", - "sha256:076cc0bc34f1966d920a49f18b52b6ad559fbe656a0748e3535cf7b3f29ebf9e" - ], - "version": "==0.16.12" - }, - "ruamel.yaml.clib": { - "hashes": [ - "sha256:058a1cc3df2a8aecc12f983a48bda99315cebf55a3b3a5463e37bb599b05727b", - "sha256:2602e91bd5c1b874d6f93d3086f9830f3e907c543c7672cf293a97c3fabdcd91", - "sha256:28116f204103cb3a108dfd37668f20abe6e3cafd0d3fd40dba126c732457b3cc", - "sha256:2d24bd98af676f4990c4d715bcdc2a60b19c56a3fb3a763164d2d8ca0e806ba7", - "sha256:30dca9bbcbb1cc858717438218d11eafb78666759e5094dd767468c0d577a7e7", - "sha256:44c7b0498c39f27795224438f1a6be6c5352f82cb887bc33d962c3a3acc00df6", - "sha256:464e66a04e740d754170be5e740657a3b3b6d2bcc567f0c3437879a6e6087ff6", - "sha256:4df5019e7783d14b79217ad9c56edf1ba7485d614ad5a385d1b3c768635c81c0", - "sha256:4e52c96ca66de04be42ea2278012a2342d89f5e82b4512fb6fb7134e377e2e62", - "sha256:5254af7d8bdf4d5484c089f929cb7f5bafa59b4f01d4f48adda4be41e6d29f99", - "sha256:52ae5739e4b5d6317b52f5b040b1b6639e8af68a5b8fd606a8b08658fbd0cab5", - "sha256:53b9dd1abd70e257a6e32f934ebc482dac5edb8c93e23deb663eac724c30b026", - "sha256:73b3d43e04cc4b228fa6fa5d796409ece6fcb53a6c270eb2048109cbcbc3b9c2", - "sha256:74161d827407f4db9072011adcfb825b5258a5ccb3d2cd518dd6c9edea9e30f1", - "sha256:839dd72545ef7ba78fd2aa1a5dd07b33696adf3e68fae7f31327161c1093001b", - "sha256:8e8fd0a22c9d92af3a34f91e8a2594eeb35cba90ab643c5e0e643567dc8be43e", - "sha256:a873e4d4954f865dcb60bdc4914af7eaae48fb56b60ed6daa1d6251c72f5337c", - "sha256:ab845f1f51f7eb750a78937be9f79baea4a42c7960f5a94dde34e69f3cce1988", - "sha256:b1e981fe1aff1fd11627f531524826a4dcc1f26c726235a52fcb62ded27d150f", - "sha256:b4b0d31f2052b3f9f9b5327024dc629a253a83d8649d4734ca7f35b60ec3e9e5", - "sha256:c6ac7e45367b1317e56f1461719c853fd6825226f45b835df7436bb04031fd8a", - "sha256:daf21aa33ee9b351f66deed30a3d450ab55c14242cfdfcd377798e2c0d25c9f1", - "sha256:e9f7d1d8c26a6a12c23421061f9022bb62704e38211fe375c645485f38df34a2", - "sha256:f6061a31880c1ed6b6ce341215336e2f3d0c1deccd84957b6fa8ca474b41e89f" - ], - "markers": "python_version < '3.9' and platform_python_implementation == 'CPython'", - "version": "==0.2.2" - }, - "rx": { - "hashes": [ - "sha256:0e0f2715a3452e95dcb5d6ea28ffe5742e832592bbcc67a48f394ef8ba871e6f", - "sha256:562851cfdb27fd5a218443cdbd682029684144dbafeb5dce34f6a709511282de" - ], - "markers": "python_full_version >= '3.6.0'", - "version": "==3.1.1" - }, - "s3transfer": { - "hashes": [ - "sha256:2482b4259524933a022d59da830f51bd746db62f047d6eb213f2f8855dcb8a13", - "sha256:921a37e2aefc64145e7b73d50c71bb4f26f46e4c9f414dc648c6245ff92cf7db" - ], - "version": "==0.3.3" - }, - "sendgrid": { - "hashes": [ - "sha256:9fba62068dd13922004b6a1676e21c6435709aaf7c2b978cdf1206e3d2196c60", - "sha256:d1af52f8cbb900bf79e28aa08102a503f5e26489c7b9f1d9750758a4b27c84d1" - ], - "version": "==3.6.5" - }, - "sendgrid-django": { - "hashes": [ - "sha256:fef60ba37d588e5e1c4dcbb3b8b7f8c16920e3e89432de357f6abd6157c4f5f4" - ], - "index": "pypi", - "version": "==4.2.0" - }, - "shortuuid": { - "hashes": [ - "sha256:3c11d2007b915c43bee3e10625f068d8a349e04f0d81f08f5fa08507427ebf1f", - "sha256:492c7402ff91beb1342a5898bd61ea953985bf24a41cd9f247409aa2e03c8f77" - ], - "index": "pypi", - "version": "==1.0.1" - }, - "six": { - "hashes": [ - "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", - "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" - ], - "index": "pypi", - "version": "==1.15.0" - }, - "smsapi-client": { - "hashes": [ - "sha256:24dbc95271643268fec3995ee2630165ac2abaa72795bef4945a6ed8f56f81da", - "sha256:81677a9fde0701557f0b8a8009912a817945ebd7e7e8cb8643dc426b7ec90974" - ], - "version": "==2.4.3" - }, - "sqlparse": { - "hashes": [ - "sha256:017cde379adbd6a1f15a61873f43e8274179378e95ef3fede90b5aa64d304ed0", - "sha256:0f91fd2e829c44362cbcfab3e9ae12e22badaa8a29ad5ff599f9ec109f0454e8" - ], - "markers": "python_version >= '3.5'", - "version": "==0.4.1" - }, - "twilio": { - "hashes": [ - "sha256:9d591617b22e75b26cda11a10d353e2001d990a7ca1696d92e50abfc6ecdcb73" - ], - "version": "==6.46.0" - }, - "uritemplate": { - "hashes": [ - "sha256:07620c3f3f8eed1f12600845892b0e036a2420acf513c53f7de0abd911a5894f", - "sha256:5af8ad10cec94f215e3f48112de2022e1d5a37ed427fbd88652fa908f2ab7cae" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==3.0.1" - }, - "urllib3": { - "hashes": [ - "sha256:8d7eaa5a82a1cac232164990f04874c594c9453ec55eef02eab885aa02fc17a2", - "sha256:f5321fbe4bf3fefa0efd0bfe7fb14e90909eb62a48ccda331726b4319897dd5e" - ], - "markers": "python_version != '3.4'", - "version": "==1.25.11" - }, - "whitenoise": { - "hashes": [ - "sha256:15f43b2e701821b95c9016cf469d29e2a546cb1c7dead584ba82c36f843995cf", - "sha256:9d81515f2b5b27051910996e1e860b1332e354d9e7bcf30c98f21dcb6713e0dd" - ], - "index": "pypi", - "version": "==3.3.1" - }, - "yubico-client": { - "hashes": [ - "sha256:59d818661f638e3f041fae44ba2c0569e4eb2a17865fa7cc9ad6577185c4d185", - "sha256:e3b86cd2a123105edfacad40551c7b26e9c1193d81ffe168ee704ebfd3d11162" - ], - "version": "==1.13.0" - } - }, - "develop": { - "appdirs": { - "hashes": [ - "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41", - "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128" - ], - "index": "pypi", - "version": "==1.4.4" - }, - "astroid": { - "hashes": [ - "sha256:2f4078c2a41bf377eea06d71c9d2ba4eb8f6b1af2135bec27bbbb7d8f12bb703", - "sha256:bc58d83eb610252fd8de6363e39d4f1d0619c894b0ed24603b881c02e64c7386" - ], - "markers": "python_version >= '3.5'", - "version": "==2.4.2" - }, - "attrs": { - "hashes": [ - "sha256:26b54ddbbb9ee1d34d5d3668dd37d6cf74990ab23c828c2888dccdceee395594", - "sha256:fce7fc47dfc976152e82d53ff92fa0407700c21acd20886a13777a0d20e655dc" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==20.2.0" - }, - "autopep8": { - "hashes": [ - "sha256:d21d3901cb0da6ebd1e83fc9b0dfbde8b46afc2ede4fe32fbda0c7c6118ca094" - ], - "index": "pypi", - "version": "==1.5.4" - }, - "black": { - "hashes": [ - "sha256:1c02557aa099101b9d21496f8a914e9ed2222ef70336404eeeac8edba836fbea" - ], - "index": "pypi", - "version": "==20.8b1" - }, - "click": { - "hashes": [ - "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a", - "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", - "version": "==7.1.2" - }, - "django-test-migrations": { - "hashes": [ - "sha256:d120d0287e1dd82ed62fe083747a1e99c0398d56beda52594e8391b94a41bef5", - "sha256:e5747e2ad0b7e4d3b8d9ccd40d414b0f186316d3757af022b4bbdec700897521" - ], - "index": "pypi", - "version": "==1.0.0" - }, - "importlib-metadata": { - "hashes": [ - "sha256:77a540690e24b0305878c37ffd421785a6f7e53c8b5720d211b211de8d0e95da", - "sha256:cefa1a2f919b866c5beb7c9f7b0ebb4061f30a8a9bf16d609b000e2dfaceb9c3" - ], - "markers": "python_version < '3.8'", - "version": "==2.0.0" - }, - "iniconfig": { - "hashes": [ - "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3", - "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32" - ], - "version": "==1.1.1" - }, - "isort": { - "hashes": [ - "sha256:dcab1d98b469a12a1a624ead220584391648790275560e1a43e54c5dceae65e7", - "sha256:dcaeec1b5f0eca77faea2a35ab790b4f3680ff75590bfcb7145986905aab2f58" - ], - "markers": "python_version >= '3.6' and python_version < '4.0'", - "version": "==5.6.4" - }, - "lazy-object-proxy": { - "hashes": [ - "sha256:0c4b206227a8097f05c4dbdd323c50edf81f15db3b8dc064d08c62d37e1a504d", - "sha256:194d092e6f246b906e8f70884e620e459fc54db3259e60cf69a4d66c3fda3449", - "sha256:1be7e4c9f96948003609aa6c974ae59830a6baecc5376c25c92d7d697e684c08", - "sha256:4677f594e474c91da97f489fea5b7daa17b5517190899cf213697e48d3902f5a", - "sha256:48dab84ebd4831077b150572aec802f303117c8cc5c871e182447281ebf3ac50", - "sha256:5541cada25cd173702dbd99f8e22434105456314462326f06dba3e180f203dfd", - "sha256:59f79fef100b09564bc2df42ea2d8d21a64fdcda64979c0fa3db7bdaabaf6239", - "sha256:8d859b89baf8ef7f8bc6b00aa20316483d67f0b1cbf422f5b4dc56701c8f2ffb", - "sha256:9254f4358b9b541e3441b007a0ea0764b9d056afdeafc1a5569eee1cc6c1b9ea", - "sha256:9651375199045a358eb6741df3e02a651e0330be090b3bc79f6d0de31a80ec3e", - "sha256:97bb5884f6f1cdce0099f86b907aa41c970c3c672ac8b9c8352789e103cf3156", - "sha256:9b15f3f4c0f35727d3a0fba4b770b3c4ebbb1fa907dbcc046a1d2799f3edd142", - "sha256:a2238e9d1bb71a56cd710611a1614d1194dc10a175c1e08d75e1a7bcc250d442", - "sha256:a6ae12d08c0bf9909ce12385803a543bfe99b95fe01e752536a60af2b7797c62", - "sha256:ca0a928a3ddbc5725be2dd1cf895ec0a254798915fb3a36af0964a0a4149e3db", - "sha256:cb2c7c57005a6804ab66f106ceb8482da55f5314b7fcb06551db1edae4ad1531", - "sha256:d74bb8693bf9cf75ac3b47a54d716bbb1a92648d5f781fc799347cfc95952383", - "sha256:d945239a5639b3ff35b70a88c5f2f491913eb94871780ebfabb2568bd58afc5a", - "sha256:eba7011090323c1dadf18b3b689845fd96a61ba0a1dfbd7f24b921398affc357", - "sha256:efa1909120ce98bbb3777e8b6f92237f5d5c8ea6758efea36a473e1d38f7d3e4", - "sha256:f3900e8a5de27447acbf900b4750b0ddfd7ec1ea7fbaf11dfa911141bc522af0" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.4.3" - }, - "mccabe": { - "hashes": [ - "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", - "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f" - ], - "version": "==0.6.1" - }, - "mypy-extensions": { - "hashes": [ - "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d", - "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8" - ], - "version": "==0.4.3" - }, - "packaging": { - "hashes": [ - "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8", - "sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181" - ], - "index": "pypi", - "version": "==20.4" - }, - "pathspec": { - "hashes": [ - "sha256:7d91249d21749788d07a2d0f94147accd8f845507400749ea19c1ec9054a12b0", - "sha256:da45173eb3a6f2a5a487efba21f050af2b41948be6ab52b6a1e3ff22bb8b7061" - ], - "version": "==0.8.0" - }, - "pep8": { - "hashes": [ - "sha256:b22cfae5db09833bb9bd7c8463b53e1a9c9b39f12e304a8d0bba729c501827ee", - "sha256:fe249b52e20498e59e0b5c5256aa52ee99fc295b26ec9eaa85776ffdb9fe6374" - ], - "index": "pypi", - "version": "==1.7.1" - }, - "pluggy": { - "hashes": [ - "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0", - "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==0.13.1" - }, - "py": { - "hashes": [ - "sha256:366389d1db726cd2fcfc79732e75410e5fe4d31db13692115529d34069a043c2", - "sha256:9ca6883ce56b4e8da7e79ac18787889fa5206c79dcc67fb065376cd2fe03f342" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.9.0" - }, - "pycodestyle": { - "hashes": [ - "sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367", - "sha256:c58a7d2815e0e8d7972bf1803331fb0152f867bd89adf8a01dfd55085434192e" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==2.6.0" - }, - "pylint": { - "hashes": [ - "sha256:bb4a908c9dadbc3aac18860550e870f58e1a02c9f2c204fdf5693d73be061210", - "sha256:bfe68f020f8a0fece830a22dd4d5dddb4ecc6137db04face4c3420a46a52239f" - ], - "index": "pypi", - "version": "==2.6.0" - }, - "pyparsing": { - "hashes": [ - "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", - "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" - ], - "index": "pypi", - "version": "==2.4.7" - }, - "pytest": { - "hashes": [ - "sha256:7a8190790c17d79a11f847fba0b004ee9a8122582ebff4729a082c109e81a4c9", - "sha256:8f593023c1a0f916110285b6efd7f99db07d59546e3d8c36fc60e2ab05d3be92" - ], - "index": "pypi", - "version": "==6.1.1" - }, - "pytest-django": { - "hashes": [ - "sha256:10e384e6b8912ded92db64c58be8139d9ae23fb8361e5fc139d8e4f8fc601bc2", - "sha256:26f02c16d36fd4c8672390deebe3413678d89f30720c16efb8b2a6bf63b9041f" - ], - "index": "pypi", - "version": "==4.1.0" - }, - "regex": { - "hashes": [ - "sha256:0cb23ed0e327c18fb7eac61ebbb3180ebafed5b9b86ca2e15438201e5903b5dd", - "sha256:1a065e7a6a1b4aa851a0efa1a2579eabc765246b8b3a5fd74000aaa3134b8b4e", - "sha256:1a511470db3aa97432ac8c1bf014fcc6c9fbfd0f4b1313024d342549cf86bcd6", - "sha256:1c447b0d108cddc69036b1b3910fac159f2b51fdeec7f13872e059b7bc932be1", - "sha256:2278453c6a76280b38855a263198961938108ea2333ee145c5168c36b8e2b376", - "sha256:240509721a663836b611fa13ca1843079fc52d0b91ef3f92d9bba8da12e768a0", - "sha256:4e21340c07090ddc8c16deebfd82eb9c9e1ec5e62f57bb86194a2595fd7b46e0", - "sha256:570e916a44a361d4e85f355aacd90e9113319c78ce3c2d098d2ddf9631b34505", - "sha256:59d5c6302d22c16d59611a9fd53556554010db1d47e9df5df37be05007bebe75", - "sha256:6a46eba253cedcbe8a6469f881f014f0a98819d99d341461630885139850e281", - "sha256:6f567df0601e9c7434958143aebea47a9c4b45434ea0ae0286a4ec19e9877169", - "sha256:781906e45ef1d10a0ed9ec8ab83a09b5e0d742de70e627b20d61ccb1b1d3964d", - "sha256:8469377a437dbc31e480993399fd1fd15fe26f382dc04c51c9cb73e42965cc06", - "sha256:8cd0d587aaac74194ad3e68029124c06245acaeddaae14cb45844e5c9bebeea4", - "sha256:97a023f97cddf00831ba04886d1596ef10f59b93df7f855856f037190936e868", - "sha256:a973d5a7a324e2a5230ad7c43f5e1383cac51ef4903bf274936a5634b724b531", - "sha256:af360e62a9790e0a96bc9ac845d87bfa0e4ee0ee68547ae8b5a9c1030517dbef", - "sha256:b706c70070eea03411b1761fff3a2675da28d042a1ab7d0863b3efe1faa125c9", - "sha256:bfd7a9fddd11d116a58b62ee6c502fd24cfe22a4792261f258f886aa41c2a899", - "sha256:c30d8766a055c22e39dd7e1a4f98f6266169f2de05db737efe509c2fb9c8a3c8", - "sha256:c53dc8ee3bb7b7e28ee9feb996a0c999137be6c1d3b02cb6b3c4cba4f9e5ed09", - "sha256:c95d514093b80e5309bdca5dd99e51bcf82c44043b57c34594d9d7556bd04d05", - "sha256:d43cf21df524283daa80ecad551c306b7f52881c8d0fe4e3e76a96b626b6d8d8", - "sha256:d62205f00f461fe8b24ade07499454a3b7adf3def1225e258b994e2215fd15c5", - "sha256:e289a857dca3b35d3615c3a6a438622e20d1bf0abcb82c57d866c8d0be3f44c4", - "sha256:e5f6aa56dda92472e9d6f7b1e6331f4e2d51a67caafff4d4c5121cadac03941e", - "sha256:f4b1c65ee86bfbf7d0c3dfd90592a9e3d6e9ecd36c367c884094c050d4c35d04" - ], - "version": "==2020.10.23" - }, - "six": { - "hashes": [ - "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", - "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" - ], - "index": "pypi", - "version": "==1.15.0" - }, - "toml": { - "hashes": [ - "sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f", - "sha256:bda89d5935c2eac546d648028b9901107a595863cb36bae0c73ac804a9b4ce88" - ], - "version": "==0.10.1" - }, - "typed-ast": { - "hashes": [ - "sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355", - "sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919", - "sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa", - "sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652", - "sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75", - "sha256:4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01", - "sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d", - "sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1", - "sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907", - "sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c", - "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3", - "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b", - "sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614", - "sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb", - "sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b", - "sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41", - "sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6", - "sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34", - "sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe", - "sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4", - "sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7" - ], - "markers": "python_version < '3.8' and implementation_name == 'cpython'", - "version": "==1.4.1" - }, - "typing-extensions": { - "hashes": [ - "sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918", - "sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c", - "sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f" - ], - "version": "==3.7.4.3" - }, - "wrapt": { - "hashes": [ - "sha256:b62ffa81fb85f4332a4f609cab4ac40709470da05643a082ec1eb88e6d9b97d7" - ], - "version": "==1.12.1" - }, - "zipp": { - "hashes": [ - "sha256:16522f69653f0d67be90e8baa4a46d66389145b734345d68a257da53df670903", - "sha256:c1532a8030c32fd52ff6a288d855fe7adef5823ba1d26a29a68fd6314aa72baa" - ], - "markers": "python_version >= '3.6'", - "version": "==3.3.1" - } - } -} diff --git a/readme.md b/readme.md index 0ef0ae32a623..9bd1891027f5 100644 --- a/readme.md +++ b/readme.md @@ -10,10 +10,11 @@ Before running the application, you'll need to configure a database for the appl to do this can be found in the following section entitled 'Databases'. ``` -pip install pipenv -pipenv install -pipenv run python src/manage.py migrate -pipenv run python src/manage.py runserver +virtualenv .venv +source .venv/bin/activate +pip install -r requirements-dev.txt +python src/manage.py migrate +python src/manage.py runserver ``` The application can also be run locally using Docker Compose if required, however, it's beneficial @@ -65,7 +66,7 @@ The application is built using django which comes with a handy set of admin page command: ``` -pipenv run python src/manage.py createsuperuser +python src/manage.py createsuperuser ``` Once you've created the super user, you can use the details to log in at `/admin/`. From here, you @@ -153,13 +154,8 @@ python secret-key-gen.py ``` ## Adding dependencies -To add a python dependency, run the following commands: +To add a python dependency, add it to requirements.txt / requirements-dev.txt with it's current version number. -``` -pipenv install -``` - -The dependency then needs to be added to the relevant requirements*.txt files as necessary. ## Caching diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 000000000000..aabfb3454da2 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,9 @@ +-r requirements.txt +pylint +pep8 +autopep8 +pytest +pytest-django +django-test-migrations +black +django-debug-toolbar \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 000000000000..18fe5322533a --- /dev/null +++ b/requirements.txt @@ -0,0 +1,33 @@ +appdirs==1.4.4 +django-cors-headers==3.5.0 +djangorestframework==3.12.1 +gunicorn==20.0.4 +pyparsing==2.4.7 +requests==2.24.0 +six==1.15.0 +whitenoise<4.0 +dj-database-url==0.5.0 +drf-nested-routers==0.92.1 +shortuuid==1.0.1 +sendgrid-django==4.2.0 +psycopg2-binary==2.8.6 +coreapi==2.3.3 +Django<3.0 +django-simple-history==2.12.0 +google-api-python-client==1.12.5 +"oauth2client"==4.1.3 +djangorestframework-recursive==0.1.2 +packaging==20.4 +chargebee==2.7.7 +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==3.14.3 +django-storages==1.10.1 +django-environ==0.4.5 +django-trench==0.2.3 +djoser==2.0.5 +influxdb-client==1.11.0 +django-ordered-model==3.4.1 +django-ses==1.0.3 +django-axes==5.8.0 +django-admin-sso==3.0.0 +drf-yasg2==1.19.3 \ No newline at end of file From 3a370a9cf217fcd6d6220320390c2b01838ca402 Mon Sep 17 00:00:00 2001 From: Ben Rometsch Date: Wed, 18 Nov 2020 14:26:36 +0000 Subject: [PATCH 10/47] Fixed requirements Fixed test to require chargebee --- requirements.txt | 2 +- src/organisations/tests/test_views.py | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 18fe5322533a..0d5b236e5a71 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,7 +15,7 @@ coreapi==2.3.3 Django<3.0 django-simple-history==2.12.0 google-api-python-client==1.12.5 -"oauth2client"==4.1.3 +oauth2client==4.1.3 djangorestframework-recursive==0.1.2 packaging==20.4 chargebee==2.7.7 diff --git a/src/organisations/tests/test_views.py b/src/organisations/tests/test_views.py index ecc31aa3a8da..2dddf6716233 100644 --- a/src/organisations/tests/test_views.py +++ b/src/organisations/tests/test_views.py @@ -1,6 +1,7 @@ import json from datetime import datetime, timedelta from unittest import TestCase, mock +from django.test.utils import override_settings import pytest from django.conf import settings @@ -9,7 +10,7 @@ from django.urls import reverse from pytz import UTC from rest_framework import status -from rest_framework.test import APIClient +from rest_framework.test import APIClient, override_settings from environments.models import Environment from features.models import Feature, FeatureSegment @@ -250,6 +251,7 @@ def test_should_get_usage_for_organisation(self, mock_influxdb_client): assert response.status_code == status.HTTP_200_OK mock_influxdb_client.query_api.return_value.query.assert_called_once_with(org=influx_org, query=query) + @override_settings(ENABLE_CHARGEBEE=True) @mock.patch('organisations.serializers.get_subscription_data_from_hosted_page') def test_update_subscription_gets_subscription_data_from_chargebee(self, mock_get_subscription_data): # Given From d41ba15ee276c216052bc9b512bbec45e5c5d54e Mon Sep 17 00:00:00 2001 From: Ben Rometsch Date: Wed, 18 Nov 2020 14:58:58 +0000 Subject: [PATCH 11/47] Version bump --- version.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.txt b/version.txt index e3a4f193364d..276cbf9e2858 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -2.2.0 \ No newline at end of file +2.3.0 From 204113e074ff28fdc139d1ea2d0b231c934a4be6 Mon Sep 17 00:00:00 2001 From: Ben Rometsch Date: Wed, 18 Nov 2020 15:56:15 +0000 Subject: [PATCH 12/47] Removed pipenv --- Dockerfile | 24 +++--------------------- Dockerfile.dev | 11 +++++------ 2 files changed, 8 insertions(+), 27 deletions(-) diff --git a/Dockerfile b/Dockerfile index 60cf3246727f..22f8b6dc5de0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,29 +1,11 @@ -FROM python:3.8 as build - -#RUN rm /var/lib/dpkg/info/format -#RUN printf "1\n" > /var/lib/dpkg/info/format -#RUN dpkg --configure -a -#RUN apt-get clean && apt-get update \ -# && apt-get install -y --no-install-recommends \ -# postgresql-client \ -# && rm -rf /var/lib/apt/lists/* \ -# && apt-get purge -y --auto-remove gcc - -RUN pip install pipenv - -WORKDIR /app -COPY Pipfile Pipfile.lock /app/ -RUN bash -c 'PIPENV_VENV_IN_PROJECT=1 pipenv install' - - FROM python:3.8-slim as application WORKDIR /app -COPY --from=build /app /app/ - +COPY requirements.txt /app/ COPY src/ /app/src/ COPY bin/ /app/bin/ -COPY Pipfile* /app/ + +RUN pip install -r requirements.txt ENV DJANGO_SETTINGS_MODULE=app.settings.master-docker EXPOSE 8000 diff --git a/Dockerfile.dev b/Dockerfile.dev index c2fe93505ed6..498e901e0b59 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -1,4 +1,4 @@ -FROM python:3.7 +FROM python:3.8 ENV PYTHONUNBUFFERED 1 RUN rm /var/lib/dpkg/info/format @@ -9,14 +9,13 @@ RUN apt-get clean && apt-get update \ postgresql-client \ && rm -rf /var/lib/apt/lists/* -RUN pip install pipenv RUN mkdir /app WORKDIR /app +COPY requirements.txt /app/ +COPY src/ /app/src/ +COPY bin/ /app/bin/ -COPY src/ /app/ -COPY bin/ ./bin/ -COPY Pipfile* /app/ +RUN pip install -r requirements.txt -RUN pipenv install ENV DJANGO_SETTINGS_MODULE=app.settings.master-docker EXPOSE 8000 From 06c495258e87af41e5f26e477eb6b5b8da172db9 Mon Sep 17 00:00:00 2001 From: Ben Rometsch Date: Wed, 18 Nov 2020 16:03:13 +0000 Subject: [PATCH 13/47] Removed pipfile --- bin/docker | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bin/docker b/bin/docker index 6e97d6d4e46d..ed3c72ef63ac 100755 --- a/bin/docker +++ b/bin/docker @@ -1,6 +1,6 @@ #!/bin/bash set -e -.venv/bin/python src/manage.py migrate -.venv/bin/python src/manage.py collectstatic --no-input -.venv/bin/gunicorn --bind 0.0.0.0:8000 -w ${GUNICORN_WORKERS:-3} -w ${GUNICORN_THREADS:-2} --pythonpath src app.wsgi +python src/manage.py migrate +python src/manage.py collectstatic --no-input +gunicorn --bind 0.0.0.0:8000 -w ${GUNICORN_WORKERS:-3} -w ${GUNICORN_THREADS:-2} --pythonpath src app.wsgi From 127af07cc6e128ed27d4a9078da0983ee7928632 Mon Sep 17 00:00:00 2001 From: Ben Rometsch Date: Wed, 18 Nov 2020 16:32:42 +0000 Subject: [PATCH 14/47] moved debug toolbar to local only --- Dockerfile.dev | 2 +- src/app/settings/common.py | 2 -- src/app/settings/local.py | 4 ++++ 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/Dockerfile.dev b/Dockerfile.dev index 498e901e0b59..c9df1eee9649 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -15,7 +15,7 @@ COPY requirements.txt /app/ COPY src/ /app/src/ COPY bin/ /app/bin/ -RUN pip install -r requirements.txt +RUN pip install -r requirements-dev.txt ENV DJANGO_SETTINGS_MODULE=app.settings.master-docker EXPOSE 8000 diff --git a/src/app/settings/common.py b/src/app/settings/common.py index 674d9f725f32..b9ebcc9ef1dc 100644 --- a/src/app/settings/common.py +++ b/src/app/settings/common.py @@ -104,7 +104,6 @@ 'segments', 'e2etests', 'simple_history', - 'debug_toolbar', 'drf_yasg2', 'audit', 'permissions', @@ -166,7 +165,6 @@ 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', 'simple_history.middleware.HistoryRequestMiddleware', - 'debug_toolbar.middleware.DebugToolbarMiddleware', 'app.middleware.AxesMiddleware', ] diff --git a/src/app/settings/local.py b/src/app/settings/local.py index 28e59f8d5967..da008dd0b985 100644 --- a/src/app/settings/local.py +++ b/src/app/settings/local.py @@ -4,6 +4,10 @@ ALLOWED_HOSTS.extend(['.ngrok.io', '127.0.0.1', 'localhost']) +INSTALLED_APPS.extend(['debug_toolbar']) + +MIDDLEWARE.extend(['debug_toolbar.middleware.DebugToolbarMiddleware']) + DEBUG = True DATABASES = { From e626de8bb6e8c414c7b3c85db11da549655d4f58 Mon Sep 17 00:00:00 2001 From: Ben Rometsch Date: Wed, 18 Nov 2020 16:42:39 +0000 Subject: [PATCH 15/47] Refactoring pipenv out --- bin/docker-dev | 6 +++--- docker-compose.yml | 6 +++--- requirements-dev.txt | 3 +-- requirements.txt | 3 ++- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/bin/docker-dev b/bin/docker-dev index 27d9d36099b8..bd6440e6ff3f 100755 --- a/bin/docker-dev +++ b/bin/docker-dev @@ -1,6 +1,6 @@ #!/bin/bash set -e -pipenv run python src/manage.py migrate -pipenv run python src/manage.py collectstatic --no-input -pipenv run python src/manage.py runserver 0.0.0.0:8000 +python src/manage.py migrate +python src/manage.py collectstatic --no-input +python src/manage.py runserver 0.0.0.0:8000 diff --git a/docker-compose.yml b/docker-compose.yml index 14697b330d77..229046aaa439 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,9 +9,9 @@ services: build: context: . dockerfile: docker/Dockerfile - 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" + command: bash -c "python manage.py migrate --noinput + && python manage.py collectstatic --noinput + && gunicorn --bind 0.0.0.0:8000 -w 3 app.wsgi" environment: DJANGO_DB_NAME: bullettrain DJANGO_DB_USER: postgres diff --git a/requirements-dev.txt b/requirements-dev.txt index aabfb3454da2..a8fb5cdd28da 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -5,5 +5,4 @@ autopep8 pytest pytest-django django-test-migrations -black -django-debug-toolbar \ No newline at end of file +black \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 0d5b236e5a71..90c96e689da0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -30,4 +30,5 @@ django-ordered-model==3.4.1 django-ses==1.0.3 django-axes==5.8.0 django-admin-sso==3.0.0 -drf-yasg2==1.19.3 \ No newline at end of file +drf-yasg2==1.19.3 +django-debug-toolbar==3.1.1 \ No newline at end of file From 57c30d29bad94b2648c5d722a8d40fa6433162b7 Mon Sep 17 00:00:00 2001 From: Ben Rometsch Date: Wed, 18 Nov 2020 16:57:56 +0000 Subject: [PATCH 16/47] Why am I linting markdown --- readme.md | 53 +++++++++++++++++++++++++++++------------------------ 1 file changed, 29 insertions(+), 24 deletions(-) diff --git a/readme.md b/readme.md index 9bd1891027f5..6e9a43acb7a6 100644 --- a/readme.md +++ b/readme.md @@ -9,19 +9,19 @@ Before running the application, you'll need to configure a database for the application. The steps to do this can be found in the following section entitled 'Databases'. -``` +```bash virtualenv .venv source .venv/bin/activate pip install -r requirements-dev.txt -python src/manage.py migrate +python src/manage.py migrate python src/manage.py runserver ``` The application can also be run locally using Docker Compose if required, however, it's beneficial to run locally using the above steps as it gives you hot reloading. To run using docker compose, -simply run the following command from the project root: +simply run the following command from the project root: -``` +```bash docker-compose up ``` @@ -29,21 +29,22 @@ docker-compose up We are slowly migrating the code style to use [black](https://github.com/psf/black) as a formatter. Black automatically formats the code for you, you can run the formatter -by running: +by running: -``` +```bash python -m black path/to/directory/or/file.py ``` -All new code should adhere to black formatting standards. +All new code should adhere to black formatting standards. ## Databases + Databases are configured in app/settings/\.py -The app is configured to use PostgreSQL for all environments. +The app is configured to use PostgreSQL for all environments. When running locally, you'll need a local instance of postgres running. The easiest way to do this -is to use docker which is achievable with the following command: +is to use docker which is achievable with the following command: ```docker run --name local_postgres -d -P postgres``` @@ -63,9 +64,9 @@ located in `app.settings.master-docker` The application is built using django which comes with a handy set of admin pages available at `/admin/`. To access these, you'll need to create a super user. This can be done with the following -command: +command: -``` +```bash python src/manage.py createsuperuser ``` @@ -91,13 +92,14 @@ Note that this functionality can be turned off in the settings if required by se ## Deploying ### Using Heroku-ish Platform (e.g. Heroku, Dokku, Flynn) + The application should run on any Heroku-ish platform (e.g. Dokku, Flynn) by simply adding the required git repo and pushing the code. The code for running the app is contained in the Procfile. To get it running, you'll need to add the necessary config variables as outlined below. - ### Using ElasticBeanstalk + The application will run within ElasticBeanstalk using the default Python setup. We've included the .ebextensions/ and .elasticbeanstalk/ directories which will run on ElasticBeanstalk. @@ -107,19 +109,20 @@ The changes required to run in your environment will be as follows `.ebextensions/options.config` - within the root of the project `generate.sh` will add in all environment variables that are required using your chosen CI/CD. Alternatively, you can add your own `options.config`. - ### Using Docker + The application can be configured to run using docker with simply by running the following command: -``` +```bash docker-compose up -``` +``` This will use some default settings created in the `docker-compose.yml` file located in the root of the project. These should be changed before using in any production environments. ### Environment Variables -The application relies on the following environment variables to run: + +The application relies on the following environment variables to run: * `ENV`: string representing the current running environment, e.g. 'local', 'dev', 'prod'. Defaults to 'local' * `DJANGO_ALLOWED_HOSTS`: comma separated list of hosts the application will run on in the given environment @@ -143,29 +146,30 @@ The application relies on the following environment variables to run: * `ENABLE_EMAIL_ACTIVATION`: new user registration will go via email activation flow, default False ### Creating a secret key + It is important to also set an environment variable on whatever platform you are using for `DJANGO_SECRET_KEY`. There is a function to create one in `app.settings.common` if none exists in the environment variables, however, this is not suitable for use in production. To generate a new secret key, you can use the function defined in `src/secret-key-gen.py` by simply running it from a -command prompt: +command prompt: +```bash +python secret-key-gen.py ``` -python secret-key-gen.py -``` ## Adding dependencies -To add a python dependency, add it to requirements.txt / requirements-dev.txt with it's current version number. +To add a python dependency, add it to requirements.txt / requirements-dev.txt with it's current version number. ## Caching -The application makes use of caching in a couple of locations: +The application makes use of caching in a couple of locations: 1. Environment authentication - the application utilises an in memory cache for the environment object on all endpoints that use the X-Environment-Key header. 2. Environment flags - the application utilises an in memory cache for the flags returned when calling /flags. The number of seconds this is cached for is configurable using the environment variable -`"CACHE_FLAGS_SECONDS"` +`"CACHE_FLAGS_SECONDS"` 3. Project Segments - the application utilises an in memory cache for returning the segments for a given project. The number of seconds this is cached for is configurable using the environment variable `"CACHE_PROJECT_SEGMENTS_SECONDS"`. @@ -174,9 +178,10 @@ given project. The number of seconds this is cached for is configurable using th - Python 2.7.14 - Django 1.11.13 -- DjangoRestFramework 3.8.2 +- DjangoRestFramework 3.8.2 ## Static Files + Although the application relies on very few static files, it is possible to optimise their configuration to host these static files in S3. This is done using the relevant environment variables provided above. Note, however, that in order to use the configuration, the environment that you are hosting on must have the correct AWS credentials @@ -186,7 +191,7 @@ that the static files are hosted in. ## Documentation -Further documentation can be found [here](https://docs.bullet-train.io). +Further documentation can be found [here](https://docs.bullet-train.io). ## Contributing From eb94262b5669d25bb853221a91b0912819043dbf Mon Sep 17 00:00:00 2001 From: Ben Rometsch Date: Thu, 19 Nov 2020 09:46:57 +0000 Subject: [PATCH 17/47] Fixed requirements Fixed test to require chargebee --- .gitlab-ci.yml | 12 +- Dockerfile | 24 +- Dockerfile.dev | 11 +- Pipfile | 60 -- Pipfile.lock | 1141 ------------------------- bin/docker | 6 +- bin/docker-dev | 6 +- docker-compose.yml | 6 +- readme.md | 67 +- requirements-dev.txt | 8 + requirements.txt | 34 + src/app/settings/common.py | 2 - src/app/settings/local.py | 4 + src/organisations/tests/test_views.py | 4 +- version.txt | 2 +- 15 files changed, 107 insertions(+), 1280 deletions(-) delete mode 100644 Pipfile delete mode 100644 Pipfile.lock create mode 100644 requirements-dev.txt create mode 100644 requirements.txt diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 5d2207a80341..8232d437bc4b 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -4,7 +4,7 @@ stages: - deploy-aws test: - image: kennethreitz/pipenv + image: python:3.7 stage: test services: - postgres:10.9 @@ -14,8 +14,8 @@ test: POSTGRES_USER: testuser POSTGRES_PASSWORD: testpass script: - - pipenv install --dev --pre - - pipenv run test + - pip install -r requirements-dev.txt + - pytest src -p no:warnings deploydevelop: image: ilyasemenov/gitlab-ci-git-push @@ -25,7 +25,7 @@ deploydevelop: - develop deployawsstaging: - image: bullettrain/elasticbeanstalk-pipenv + image: bullettrain/elasticbeanstalk-pipenv # TODO: remove pipenv from this docker image stage: deploy-aws script: - export AWS_ACCESS_KEY_ID=$AWS_STAGING_ACCESS_KEY_ID @@ -34,7 +34,7 @@ deployawsstaging: - export GOOGLE_ANALYTICS_CLIENT_ID=$GOOGLE_ANALYTICS_CLIENT_ID_STAGING - export GOOGLE_ANALYTICS_KEY=$GOOGLE_ANALYTICS_KEY_STAGING - export DJANGO_SETTINGS_MODULE=$DJANGO_SETTINGS_MODULE_STAGING - - pipenv lock --requirements > ./src/requirements.txt + - cp requirements.txt ./src/requirements.txt - sh generate.sh - git config --global user.email "build@gitlab.com" - git config --global user.name "Gitlab" @@ -52,7 +52,7 @@ deployawsmaster: - export GOOGLE_ANALYTICS_CLIENT_ID=$GOOGLE_ANALYTICS_CLIENT_ID_PRODUCTION - export GOOGLE_ANALYTICS_KEY=$GOOGLE_ANALYTICS_KEY_PRODUCTION - export DJANGO_SETTINGS_MODULE=$DJANGO_SETTINGS_MODULE_PRODUCTION - - pipenv lock --requirements > ./src/requirements.txt + - cp requirements.txt ./src/requirements.txt - sh generate.sh - git config --global user.email "build@gitlab.com" - git config --global user.name "Gitlab" diff --git a/Dockerfile b/Dockerfile index 60cf3246727f..22f8b6dc5de0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,29 +1,11 @@ -FROM python:3.8 as build - -#RUN rm /var/lib/dpkg/info/format -#RUN printf "1\n" > /var/lib/dpkg/info/format -#RUN dpkg --configure -a -#RUN apt-get clean && apt-get update \ -# && apt-get install -y --no-install-recommends \ -# postgresql-client \ -# && rm -rf /var/lib/apt/lists/* \ -# && apt-get purge -y --auto-remove gcc - -RUN pip install pipenv - -WORKDIR /app -COPY Pipfile Pipfile.lock /app/ -RUN bash -c 'PIPENV_VENV_IN_PROJECT=1 pipenv install' - - FROM python:3.8-slim as application WORKDIR /app -COPY --from=build /app /app/ - +COPY requirements.txt /app/ COPY src/ /app/src/ COPY bin/ /app/bin/ -COPY Pipfile* /app/ + +RUN pip install -r requirements.txt ENV DJANGO_SETTINGS_MODULE=app.settings.master-docker EXPOSE 8000 diff --git a/Dockerfile.dev b/Dockerfile.dev index c2fe93505ed6..c9df1eee9649 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -1,4 +1,4 @@ -FROM python:3.7 +FROM python:3.8 ENV PYTHONUNBUFFERED 1 RUN rm /var/lib/dpkg/info/format @@ -9,14 +9,13 @@ RUN apt-get clean && apt-get update \ postgresql-client \ && rm -rf /var/lib/apt/lists/* -RUN pip install pipenv RUN mkdir /app WORKDIR /app +COPY requirements.txt /app/ +COPY src/ /app/src/ +COPY bin/ /app/bin/ -COPY src/ /app/ -COPY bin/ ./bin/ -COPY Pipfile* /app/ +RUN pip install -r requirements-dev.txt -RUN pipenv install ENV DJANGO_SETTINGS_MODULE=app.settings.master-docker EXPOSE 8000 diff --git a/Pipfile b/Pipfile deleted file mode 100644 index 1cc00cadd5ef..000000000000 --- a/Pipfile +++ /dev/null @@ -1,60 +0,0 @@ -[[source]] -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" -test = "pytest src -p no:warnings" -shell = "src/manage.py shell" - -[dev-packages] -pylint = "*" -"pep8" = "*" -"autopep8" = "*" -pytest = "*" -pytest-django = "*" -django-test-migrations = "*" -black = "*" - -[packages] -appdirs = "*" -django-cors-headers = "*" -djangorestframework = "*" -gunicorn = "*" -pyparsing = "*" -requests = "*" -six = "*" -whitenoise = "<4.0" -dj-database-url = "*" -drf-nested-routers = "*" -shortuuid = "*" -sendgrid-django = "*" -psycopg2-binary = "*" -coreapi = "*" -Django = "<3.0" -django-simple-history = "*" -django-debug-toolbar = "*" -google-api-python-client = "*" -"oauth2client" = "*" -djangorestframework-recursive = "*" -packaging = "*" -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 = "*" -django-environ = "*" -django-trench = "*" -djoser = "*" -influxdb-client = "*" -django-ordered-model = "*" -django-ses = "*" -django-axes = "*" -django-admin-sso = "*" - -drf-yasg2 = "*" - -[pipenv] -allow_prereleases = true diff --git a/Pipfile.lock b/Pipfile.lock deleted file mode 100644 index b1fad9b01170..000000000000 --- a/Pipfile.lock +++ /dev/null @@ -1,1141 +0,0 @@ -{ - "_meta": { - "hash": { - "sha256": "5dc316c5f1c6d96ef50a52256ed643f5e3d7f449fb8117ca67bf02576e707b75" - }, - "pipfile-spec": 6, - "requires": {}, - "sources": [ - { - "name": "pypi", - "url": "https://pypi.org/simple", - "verify_ssl": true - } - ] - }, - "default": { - "appdirs": { - "hashes": [ - "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41", - "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128" - ], - "index": "pypi", - "version": "==1.4.4" - }, - "asgiref": { - "hashes": [ - "sha256:5ee950735509d04eb673bd7f7120f8fa1c9e2df495394992c73234d526907e17", - "sha256:7162a3cb30ab0609f1a4c95938fd73e8604f63bdba516a7f7d64b83ff09478f0" - ], - "markers": "python_version >= '3.5'", - "version": "==3.3.1" - }, - "boto3": { - "hashes": [ - "sha256:0a4c72ef591fd4291efb9767a6a94f625afccc1e0fc7843968cd39eb6289c3e4", - "sha256:6c5d952f97e13997b1c7463038d96469355595cd37f87b43451759cc03756322" - ], - "version": "==1.16.20" - }, - "botocore": { - "hashes": [ - "sha256:00a69a507e8b817d0703c612131e6cb3a3260579d0353c56d005bd9effd92ec0", - "sha256:4576ca751264c65420daae07e244beafdfea493b4fbb815d37215c0736dc4633" - ], - "version": "==1.19.20" - }, - "cachetools": { - "hashes": [ - "sha256:513d4ff98dd27f85743a8dc0e92f55ddb1b49e060c2d5961512855cda2c01a98", - "sha256:bbaa39c3dede00175df2dc2b03d0cf18dd2d32a7de7beb68072d13043c9edb20" - ], - "markers": "python_version ~= '3.5'", - "version": "==4.1.1" - }, - "certifi": { - "hashes": [ - "sha256:1f422849db327d534e3d0c5f02a263458c3955ec0aae4ff09b95f195c59f4edd", - "sha256:f05def092c44fbf25834a51509ef6e631dc19765ab8a57b4e7ab85531f0a9cf4" - ], - "version": "==2020.11.8" - }, - "cffi": { - "hashes": [ - "sha256:005f2bfe11b6745d726dbb07ace4d53f057de66e336ff92d61b8c7e9c8f4777d", - "sha256:09e96138280241bd355cd585148dec04dbbedb4f46128f340d696eaafc82dd7b", - "sha256:0b1ad452cc824665ddc682400b62c9e4f5b64736a2ba99110712fdee5f2505c4", - "sha256:0ef488305fdce2580c8b2708f22d7785ae222d9825d3094ab073e22e93dfe51f", - "sha256:15f351bed09897fbda218e4db5a3d5c06328862f6198d4fb385f3e14e19decb3", - "sha256:22399ff4870fb4c7ef19fff6eeb20a8bbf15571913c181c78cb361024d574579", - "sha256:23e5d2040367322824605bc29ae8ee9175200b92cb5483ac7d466927a9b3d537", - "sha256:2791f68edc5749024b4722500e86303a10d342527e1e3bcac47f35fbd25b764e", - "sha256:2f9674623ca39c9ebe38afa3da402e9326c245f0f5ceff0623dccdac15023e05", - "sha256:3363e77a6176afb8823b6e06db78c46dbc4c7813b00a41300a4873b6ba63b171", - "sha256:33c6cdc071ba5cd6d96769c8969a0531be2d08c2628a0143a10a7dcffa9719ca", - "sha256:3b8eaf915ddc0709779889c472e553f0d3e8b7bdf62dab764c8921b09bf94522", - "sha256:3cb3e1b9ec43256c4e0f8d2837267a70b0e1ca8c4f456685508ae6106b1f504c", - "sha256:3eeeb0405fd145e714f7633a5173318bd88d8bbfc3dd0a5751f8c4f70ae629bc", - "sha256:44f60519595eaca110f248e5017363d751b12782a6f2bd6a7041cba275215f5d", - "sha256:4d7c26bfc1ea9f92084a1d75e11999e97b62d63128bcc90c3624d07813c52808", - "sha256:529c4ed2e10437c205f38f3691a68be66c39197d01062618c55f74294a4a4828", - "sha256:6642f15ad963b5092d65aed022d033c77763515fdc07095208f15d3563003869", - "sha256:85ba797e1de5b48aa5a8427b6ba62cf69607c18c5d4eb747604b7302f1ec382d", - "sha256:8f0f1e499e4000c4c347a124fa6a27d37608ced4fe9f7d45070563b7c4c370c9", - "sha256:a624fae282e81ad2e4871bdb767e2c914d0539708c0f078b5b355258293c98b0", - "sha256:b0358e6fefc74a16f745afa366acc89f979040e0cbc4eec55ab26ad1f6a9bfbc", - "sha256:bbd2f4dfee1079f76943767fce837ade3087b578aeb9f69aec7857d5bf25db15", - "sha256:bf39a9e19ce7298f1bd6a9758fa99707e9e5b1ebe5e90f2c3913a47bc548747c", - "sha256:c11579638288e53fc94ad60022ff1b67865363e730ee41ad5e6f0a17188b327a", - "sha256:c150eaa3dadbb2b5339675b88d4573c1be3cb6f2c33a6c83387e10cc0bf05bd3", - "sha256:c53af463f4a40de78c58b8b2710ade243c81cbca641e34debf3396a9640d6ec1", - "sha256:cb763ceceae04803adcc4e2d80d611ef201c73da32d8f2722e9d0ab0c7f10768", - "sha256:cc75f58cdaf043fe6a7a6c04b3b5a0e694c6a9e24050967747251fb80d7bce0d", - "sha256:d80998ed59176e8cba74028762fbd9b9153b9afc71ea118e63bbf5d4d0f9552b", - "sha256:de31b5164d44ef4943db155b3e8e17929707cac1e5bd2f363e67a56e3af4af6e", - "sha256:e66399cf0fc07de4dce4f588fc25bfe84a6d1285cc544e67987d22663393926d", - "sha256:f0620511387790860b249b9241c2f13c3a80e21a73e0b861a2df24e9d6f56730", - "sha256:f4eae045e6ab2bb54ca279733fe4eb85f1effda392666308250714e01907f394", - "sha256:f92cdecb618e5fa4658aeb97d5eb3d2f47aa94ac6477c6daf0f306c5a3b9e6b1", - "sha256:f92f789e4f9241cd262ad7a555ca2c648a98178a953af117ef7fad46aa1d5591" - ], - "version": "==1.14.3" - }, - "chardet": { - "hashes": [ - "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", - "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" - ], - "version": "==3.0.4" - }, - "chargebee": { - "hashes": [ - "sha256:95b1f7ff91737103f6115edb5f2329f525a4f181770597c6b9d2683628f9a92d" - ], - "index": "pypi", - "version": "==2.7.8" - }, - "coreapi": { - "hashes": [ - "sha256:46145fcc1f7017c076a2ef684969b641d18a2991051fddec9458ad3f78ffc1cb", - "sha256:bf39d118d6d3e171f10df9ede5666f63ad80bba9a29a8ec17726a66cf52ee6f3" - ], - "index": "pypi", - "version": "==2.3.3" - }, - "coreschema": { - "hashes": [ - "sha256:5e6ef7bf38c1525d5e55a895934ab4273548629f16aed5c0a6caa74ebf45551f", - "sha256:9503506007d482ab0867ba14724b93c18a33b22b6d19fb419ef2d239dd4a1607" - ], - "version": "==0.0.4" - }, - "cryptography": { - "hashes": [ - "sha256:07ca431b788249af92764e3be9a488aa1d39a0bc3be313d826bbec690417e538", - "sha256:13b88a0bd044b4eae1ef40e265d006e34dbcde0c2f1e15eb9896501b2d8f6c6f", - "sha256:32434673d8505b42c0de4de86da8c1620651abd24afe91ae0335597683ed1b77", - "sha256:3cd75a683b15576cfc822c7c5742b3276e50b21a06672dc3a800a2d5da4ecd1b", - "sha256:4e7268a0ca14536fecfdf2b00297d4e407da904718658c1ff1961c713f90fd33", - "sha256:545a8550782dda68f8cdc75a6e3bf252017aa8f75f19f5a9ca940772fc0cb56e", - "sha256:55d0b896631412b6f0c7de56e12eb3e261ac347fbaa5d5e705291a9016e5f8cb", - "sha256:5849d59358547bf789ee7e0d7a9036b2d29e9a4ddf1ce5e06bb45634f995c53e", - "sha256:6dc59630ecce8c1f558277ceb212c751d6730bd12c80ea96b4ac65637c4f55e7", - "sha256:7117319b44ed1842c617d0a452383a5a052ec6aa726dfbaffa8b94c910444297", - "sha256:75e8e6684cf0034f6bf2a97095cb95f81537b12b36a8fedf06e73050bb171c2d", - "sha256:7b8d9d8d3a9bd240f453342981f765346c87ade811519f98664519696f8e6ab7", - "sha256:a035a10686532b0587d58a606004aa20ad895c60c4d029afa245802347fab57b", - "sha256:a4e27ed0b2504195f855b52052eadcc9795c59909c9d84314c5408687f933fc7", - "sha256:a733671100cd26d816eed39507e585c156e4498293a907029969234e5e634bc4", - "sha256:a75f306a16d9f9afebfbedc41c8c2351d8e61e818ba6b4c40815e2b5740bb6b8", - "sha256:bd717aa029217b8ef94a7d21632a3bb5a4e7218a4513d2521c2a2fd63011e98b", - "sha256:d25cecbac20713a7c3bc544372d42d8eafa89799f492a43b79e1dfd650484851", - "sha256:d26a2557d8f9122f9bf445fc7034242f4375bd4e95ecda007667540270965b13", - "sha256:d3545829ab42a66b84a9aaabf216a4dce7f16dbc76eb69be5c302ed6b8f4a29b", - "sha256:d3d5e10be0cf2a12214ddee45c6bd203dab435e3d83b4560c03066eda600bfe3", - "sha256:efe15aca4f64f3a7ea0c09c87826490e50ed166ce67368a68f315ea0807a20df" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", - "version": "==3.2.1" - }, - "defusedxml": { - "hashes": [ - "sha256:6687150770438374ab581bb7a1b327a847dd9c5749e396102de3fad4e8a3ef93", - "sha256:f684034d135af4c6cbb949b8a4d2ed61634515257a67299e5f940fbaa34377f5" - ], - "markers": "python_version >= '3.0'", - "version": "==0.6.0" - }, - "dj-database-url": { - "hashes": [ - "sha256:4aeaeb1f573c74835b0686a2b46b85990571159ffc21aa57ecd4d1e1cb334163", - "sha256:851785365761ebe4994a921b433062309eb882fedd318e1b0fcecc607ed02da9" - ], - "index": "pypi", - "version": "==0.5.0" - }, - "django": { - "hashes": [ - "sha256:558cb27930defd9a6042133258caf797b2d1dee233959f537e3dc475cb49bd7c", - "sha256:cf5370a4d7765a9dd6d42a7b96b53c74f9446cd38209211304b210fe0404b861" - ], - "index": "pypi", - "version": "==2.2.17" - }, - "django-admin-sso": { - "hashes": [ - "sha256:1f11298f9a0fe7c34acfae057ae8e19637046fef0cd4b5b3fa0fcebb1e5526a9", - "sha256:32548dc797296642d4f7b51d37a48fd2fbfd938e2db01f738c4ef4acc16d1d5e" - ], - "index": "pypi", - "version": "==3.0.0" - }, - "django-axes": { - "hashes": [ - "sha256:25827cd722b006845e9abd5ecbfba92ebad3935a29258d457158212d4e1f0ded", - "sha256:320ddc577387b1b7926468b9313fd8048f86b70e35a02faa858cbac003900374" - ], - "index": "pypi", - "version": "==5.9.0" - }, - "django-cors-headers": { - "hashes": [ - "sha256:9322255c296d5f75089571f29e520c83ff9693df17aa3cf9f6a4bea7c6740169", - "sha256:db82b2840f667d47872ae3e4a4e0a0d72fbecb42779b8aa233fa8bb965f7836a" - ], - "index": "pypi", - "version": "==3.5.0" - }, - "django-debug-toolbar": { - "hashes": [ - "sha256:a1ce0665f7ef47d27b8df4b5d1058748e1f08500a01421a30d35164f38aaaf4c", - "sha256:c97921a9cd421d392e7860dc4b464db8e06c8628df4dc58fedab012888c293c6" - ], - "index": "pypi", - "version": "==3.1.1" - }, - "django-environ": { - "hashes": [ - "sha256:6c9d87660142608f63ec7d5ce5564c49b603ea8ff25da595fd6098f6dc82afde", - "sha256:c57b3c11ec1f319d9474e3e5a79134f40174b17c7cc024bbb2fad84646b120c4" - ], - "index": "pypi", - "version": "==0.4.5" - }, - "django-health-check": { - "hashes": [ - "sha256:2cb3944e313e435bdf299288e109f398b6c08b610e09cc90d7f5f6a2bcf469fc", - "sha256:8b0835f04ebaeb0d12498a5ef47dd22196237c3987ff28bcce9ed28b5a169d5e" - ], - "index": "pypi", - "version": "==3.16.1" - }, - "django-ipware": { - "hashes": [ - "sha256:c7df8e1410a8e5d6b1fbae58728402ea59950f043c3582e033e866f0f0cf5e94" - ], - "version": "==3.0.2" - }, - "django-ordered-model": { - "hashes": [ - "sha256:29af6624cf3505daaf0df00e2df1d0726dd777b95e08f304d5ad0264092aa934", - "sha256:d867166ed4dd12501139e119cbbc5b4d19798a3e72740aef0af4879ba97102cf" - ], - "index": "pypi", - "version": "==3.4.1" - }, - "django-ses": { - "hashes": [ - "sha256:45f041acb9f2f8df85f3fddd63b04f1d381982bdc7730a3dc1f88e5795758bdd", - "sha256:9c0a3e59e1e2424093820fa7cd519aa35f9ba978c26917a97a30c682449f0df6" - ], - "index": "pypi", - "version": "==1.0.3" - }, - "django-simple-history": { - "hashes": [ - "sha256:3b7bf6bfbcf973afca123c5786c72b917ed4d92d7bf3b6cb70fe2e3850e763a3", - "sha256:e7e830cb7a768dc90d6ba0507f8023f889bcb62fe31a08f18fac102c55eec539" - ], - "index": "pypi", - "version": "==2.12.0" - }, - "django-storages": { - "hashes": [ - "sha256:12de8fb2605b9b57bfaf54b075280d7cbb3b3ee1ca4bc9b9add147af87fe3a2c", - "sha256:652275ab7844538c462b62810276c0244866f345878256a9e0e86f5b1283ae18" - ], - "index": "pypi", - "version": "==1.10.1" - }, - "django-templated-mail": { - "hashes": [ - "sha256:8db807effebb42a532622e2d142dfd453dafcd0d7794c4c3332acb90656315f9", - "sha256:f7127e1e31d7cad4e6c4b4801d25814d4b8782627ead76f4a75b3b7650687556" - ], - "version": "==1.1.1" - }, - "django-trench": { - "hashes": [ - "sha256:63e189a057c45198d178ea79337e690250b484fcd8ff2057c9fd4b3699639853" - ], - "index": "pypi", - "version": "==0.2.3" - }, - "djangorestframework": { - "hashes": [ - "sha256:0209bafcb7b5010fdfec784034f059d512256424de2a0f084cb82b096d6dd6a7" - ], - "index": "pypi", - "version": "==3.12.2" - }, - "djangorestframework-recursive": { - "hashes": [ - "sha256:e4e51b26b7ee3c9f9b838885d638b91293e7c66e85b5955f278a6e10eb34ce7c", - "sha256:f8fc2d677ccb32fe53ec4153a45f66c822d0ce444824cba56edc76ca89b704ae" - ], - "index": "pypi", - "version": "==0.1.2" - }, - "djangorestframework-simplejwt": { - "hashes": [ - "sha256:7adc913ba0d2ed7f46e0b9bf6e86f9bd9248f1c4201722b732b8213e0ea66f9f", - "sha256:bd587700b6ab34a6c6b12d426cce4fa580d57ef1952ad4ba3b79707784619ed3" - ], - "markers": "python_version >= '3.7'", - "version": "==4.6.0" - }, - "djoser": { - "hashes": [ - "sha256:3299073aa5822f9ad02bc872b87e719051c07d36cdc87a05b2afdb2c3bad46d1", - "sha256:9590378d59eb3243572bcb6b0a45268a3e31bedddc15235ca248a18c7bc0ffe6" - ], - "index": "pypi", - "version": "==2.1.0" - }, - "drf-nested-routers": { - "hashes": [ - "sha256:52be428b046078ed21e9137167035cc2eb8daad036900fd2235ce026306e143a", - "sha256:e043fc937f94ac462a92d2d9fc9a7e55710a67164b558442adfe9634fc519c3b" - ], - "index": "pypi", - "version": "==0.92.1" - }, - "drf-yasg2": { - "hashes": [ - "sha256:7037a8041eb5d1073fa504a284fc889685f93d0bfd008a963db1b366db786734", - "sha256:75e661ca5cf15eb44fcfab408c7b864f87c20794f564aa08b3a31817a857f19d" - ], - "index": "pypi", - "version": "==1.19.4" - }, - "future": { - "hashes": [ - "sha256:b1bead90b70cf6ec3f0710ae53a525360fa360d306a86583adc6bf83a4db537d" - ], - "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==0.18.2" - }, - "google-api-core": { - "hashes": [ - "sha256:1bb3c485c38eacded8d685b1759968f6cf47dd9432922d34edb90359eaa391e2", - "sha256:94d8c707d358d8d9e8b0045c42be20efb58433d308bd92cf748511c7825569c8" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.23.0" - }, - "google-api-python-client": { - "hashes": [ - "sha256:1f5cfcb92c8e3bd0a69cb2ff3cefa5ff16ffa1900af795a53f5bed93c8238951", - "sha256:608552e52ea994a014be8bb0489923328a50776190e0858caaf7b186ebad22bf" - ], - "index": "pypi", - "version": "==1.12.6" - }, - "google-auth": { - "hashes": [ - "sha256:5176db85f1e7e837a646cd9cede72c3c404ccf2e3373d9ee14b2db88febad440", - "sha256:b728625ff5dfce8f9e56a499c8a4eb51443a67f20f6d28b67d5774c310ec4b6b" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.23.0" - }, - "google-auth-httplib2": { - "hashes": [ - "sha256:8d092cc60fb16517b12057ec0bba9185a96e3b7169d86ae12eae98e645b7bc39", - "sha256:aeaff501738b289717fac1980db9711d77908a6c227f60e4aa1923410b43e2ee" - ], - "version": "==0.0.4" - }, - "googleapis-common-protos": { - "hashes": [ - "sha256:560716c807117394da12cecb0a54da5a451b5cf9866f1d37e9a5e2329a665351", - "sha256:c8961760f5aad9a711d37b675be103e0cc4e9a39327e0d6d857872f698403e24" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.52.0" - }, - "gunicorn": { - "hashes": [ - "sha256:1904bb2b8a43658807108d59c3f3d56c2b6121a701161de0ddf9ad140073c626", - "sha256:cd4a810dd51bf497552cf3f863b575dabd73d6ad6a91075b65936b151cbf4f9c" - ], - "index": "pypi", - "version": "==20.0.4" - }, - "httplib2": { - "hashes": [ - "sha256:8af66c1c52c7ffe1aa5dc4bcd7c769885254b0756e6e69f953c7f0ab49a70ba3", - "sha256:ca2914b015b6247791c4866782fa6042f495b94401a0f0bd3e1d6e0ba2236782" - ], - "version": "==0.18.1" - }, - "idna": { - "hashes": [ - "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6", - "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==2.10" - }, - "inflection": { - "hashes": [ - "sha256:1a29730d366e996aaacffb2f1f1cb9593dc38e2ddd30c91250c6dde09ea9b417", - "sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2" - ], - "markers": "python_version >= '3.5'", - "version": "==0.5.1" - }, - "influxdb-client": { - "hashes": [ - "sha256:213cece87fbb71411c6d387d65e0cbc3d529bc37c734635e6ee4449443757331", - "sha256:f233da171a2508274d4cebba3ec66d2cc4729229e66419aabeb20220e3b36c82" - ], - "index": "pypi", - "version": "==1.12.0" - }, - "itypes": { - "hashes": [ - "sha256:03da6872ca89d29aef62773672b2d408f490f80db48b23079a4b194c86dd04c6", - "sha256:af886f129dea4a2a1e3d36595a2d139589e4dd287f5cab0b40e799ee81570ff1" - ], - "version": "==1.2.0" - }, - "jinja2": { - "hashes": [ - "sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0", - "sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", - "version": "==2.11.2" - }, - "jmespath": { - "hashes": [ - "sha256:b85d0567b8666149a93172712e68920734333c0ce7e89b78b3e987f71e5ed4f9", - "sha256:cdf6525904cc597730141d61b36f2e4b8ecc257c420fa2f4549bac2c2d0cb72f" - ], - "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==0.10.0" - }, - "markupsafe": { - "hashes": [ - "sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473", - "sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161", - "sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235", - "sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5", - "sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42", - "sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff", - "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b", - "sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1", - "sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e", - "sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183", - "sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66", - "sha256:596510de112c685489095da617b5bcbbac7dd6384aeebeda4df6025d0256a81b", - "sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1", - "sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15", - "sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1", - "sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e", - "sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b", - "sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905", - "sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735", - "sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d", - "sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e", - "sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d", - "sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c", - "sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21", - "sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2", - "sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5", - "sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b", - "sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6", - "sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f", - "sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f", - "sha256:cdb132fc825c38e1aeec2c8aa9338310d29d337bebbd7baa06889d09a60a1fa2", - "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7", - "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.1.1" - }, - "oauth2client": { - "hashes": [ - "sha256:b8a81cc5d60e2d364f0b1b98f958dbd472887acaf1a5b05e21c28c31a2d6d3ac", - "sha256:d486741e451287f69568a4d26d70d9acd73a2bbfa275746c535b4209891cccc6" - ], - "index": "pypi", - "version": "==4.1.3" - }, - "oauthlib": { - "hashes": [ - "sha256:bee41cc35fcca6e988463cacc3bcb8a96224f470ca547e697b604cc697b2f889", - "sha256:df884cd6cbe20e32633f1db1072e9356f53638e4361bef4e8b03c9127c9328ea" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==3.1.0" - }, - "packaging": { - "hashes": [ - "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8", - "sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181" - ], - "index": "pypi", - "version": "==20.4" - }, - "protobuf": { - "hashes": [ - "sha256:0e247612fadda953047f53301a7b0407cb0c3cb4ae25a6fde661597a04039b3c", - "sha256:0fc96785262042e4863b3f3b5c429d4636f10d90061e1840fce1baaf59b1a836", - "sha256:1c51fda1bbc9634246e7be6016d860be01747354ed7015ebe38acf4452f470d2", - "sha256:1d63eb389347293d8915fb47bee0951c7b5dab522a4a60118b9a18f33e21f8ce", - "sha256:22bcd2e284b3b1d969c12e84dc9b9a71701ec82d8ce975fdda19712e1cfd4e00", - "sha256:2a7e2fe101a7ace75e9327b9c946d247749e564a267b0515cf41dfe450b69bac", - "sha256:43b554b9e73a07ba84ed6cf25db0ff88b1e06be610b37656e292e3cbb5437472", - "sha256:4b74301b30513b1a7494d3055d95c714b560fbb630d8fb9956b6f27992c9f980", - "sha256:4e75105c9dfe13719b7293f75bd53033108f4ba03d44e71db0ec2a0e8401eafd", - "sha256:5b7a637212cc9b2bcf85dd828b1178d19efdf74dbfe1ddf8cd1b8e01fdaaa7f5", - "sha256:5e9806a43232a1fa0c9cf5da8dc06f6910d53e4390be1fa06f06454d888a9142", - "sha256:629b03fd3caae7f815b0c66b41273f6b1900a579e2ccb41ef4493a4f5fb84f3a", - "sha256:72230ed56f026dd664c21d73c5db73ebba50d924d7ba6b7c0d81a121e390406e", - "sha256:86a75477addde4918e9a1904e5c6af8d7b691f2a3f65587d73b16100fbe4c3b2", - "sha256:8971c421dbd7aad930c9bd2694122f332350b6ccb5202a8b7b06f3f1a5c41ed5", - "sha256:9616f0b65a30851e62f1713336c931fcd32c057202b7ff2cfbfca0fc7d5e3043", - "sha256:b0d5d35faeb07e22a1ddf8dce620860c8fe145426c02d1a0ae2688c6e8ede36d", - "sha256:ecc33531a213eee22ad60e0e2aaea6c8ba0021f0cce35dbf0ab03dee6e2a23a1" - ], - "version": "==3.14.0" - }, - "psycopg2-binary": { - "hashes": [ - "sha256:ad20d2eb875aaa1ea6d0f2916949f5c08a19c74d05b16ce6ebf6d24f2c9f75d1", - "sha256:e1f57aa70d3f7cc6947fd88636a481638263ba04a742b4a37dd25c373e41491a", - "sha256:833709a5c66ca52f1d21d41865a637223b368c0ee76ea54ca5bad6f2526c7679", - "sha256:11b9c0ebce097180129e422379b824ae21c8f2a6596b159c7659e2e5a00e1aa0", - "sha256:aaa4213c862f0ef00022751161df35804127b78adf4a2755b9f991a507e425fd", - "sha256:ba28584e6bca48c59eecbf7efb1576ca214b47f05194646b081717fa628dfddf", - "sha256:ac0c682111fbf404525dfc0f18a8b5f11be52657d4f96e9fcb75daf4f3984859", - "sha256:6422f2ff0919fd720195f64ffd8f924c1395d30f9a495f31e2392c2efafb5056", - "sha256:0deac2af1a587ae12836aa07970f5cb91964f05a7c6cdb69d8425ff4c15d4e2c", - "sha256:bd1be66dde2b82f80afb9459fc618216753f67109b859a361cf7def5c7968729", - "sha256:1fabed9ea2acc4efe4671b92c669a213db744d2af8a9fc5d69a8e9bc14b7a9db", - "sha256:d1b4ab59e02d9008efe10ceabd0b31e79519da6fb67f7d8e8977118832d0f449", - "sha256:b8a3715b3c4e604bcc94c90a825cd7f5635417453b253499664f784fc4da0152", - "sha256:d14b140a4439d816e3b1229a4a525df917d6ea22a0771a2a78332273fd9528a4", - "sha256:ee69dad2c7155756ad114c02db06002f4cded41132cc51378e57aad79cc8e4f4", - "sha256:7312e931b90fe14f925729cde58022f5d034241918a5c4f9797cac62f6b3a9dd", - "sha256:15978a1fbd225583dd8cdaf37e67ccc278b5abecb4caf6b2d6b8e2b948e953f6", - "sha256:d5227b229005a696cc67676e24c214740efd90b148de5733419ac9aaba3773da", - "sha256:a0c50db33c32594305b0ef9abc0cb7db13de7621d2cadf8392a1d9b3c437ef77", - "sha256:e74a55f6bad0e7d3968399deb50f61f4db1926acf4a6d83beaaa7df986f48b1c", - "sha256:e82aba2188b9ba309fd8e271702bd0d0fc9148ae3150532bbb474f4590039ffb", - "sha256:8cd0fb36c7412996859cb4606a35969dd01f4ea34d9812a141cd920c3b18be77", - "sha256:89705f45ce07b2dfa806ee84439ec67c5d9a0ef20154e0e475e2b2ed392a5b83", - "sha256:2dac98e85565d5688e8ab7bdea5446674a83a3945a8f416ad0110018d1501b94", - "sha256:ba381aec3a5dc29634f20692349d73f2d21f17653bda1decf0b52b11d694541f", - "sha256:7d92a09b788cbb1aec325af5fcba9fed7203897bbd9269d5691bb1e3bce29550", - "sha256:c2507d796fca339c8fb03216364cca68d87e037c1f774977c8fc377627d01c71", - "sha256:a0eb43a07386c3f1f1ebb4dc7aafb13f67188eab896e7397aa1ee95a9c884eb2", - "sha256:b4afc542c0ac0db720cf516dd20c0846f71c248d2b3d21013aa0d4ef9c71ca25", - "sha256:f5ab93a2cb2d8338b1674be43b442a7f544a0971da062a5da774ed40587f18f5", - "sha256:950bc22bb56ee6ff142a2cb9ee980b571dd0912b0334aa3fe0fe3788d860bea2", - "sha256:42ec1035841b389e8cc3692277a0bd81cdfe0b65d575a2c8862cec7a80e62e52", - "sha256:6a32f3a4cb2f6e1a0b15215f448e8ce2da192fd4ff35084d80d5e39da683e79b", - "sha256:cec7e622ebc545dbb4564e483dd20e4e404da17ae07e06f3e780b2dacd5cee66", - "sha256:0e4dc3d5996760104746e6cfcdb519d9d2cd27c738296525d5867ea695774e67" - ], - "index": "pypi", - "version": "==2.8.6" - }, - "pyasn1": { - "hashes": [ - "sha256:014c0e9976956a08139dc0712ae195324a75e142284d5f87f1a87ee1b068a359", - "sha256:03840c999ba71680a131cfaee6fab142e1ed9bbd9c693e285cc6aca0d555e576", - "sha256:0458773cfe65b153891ac249bcf1b5f8f320b7c2ce462151f8fa74de8934becf", - "sha256:08c3c53b75eaa48d71cf8c710312316392ed40899cb34710d092e96745a358b7", - "sha256:39c7e2ec30515947ff4e87fb6f456dfc6e84857d34be479c9d4a4ba4bf46aa5d", - "sha256:5c9414dcfede6e441f7e8f81b43b34e834731003427e5b09e4e00e3172a10f00", - "sha256:6e7545f1a61025a4e58bb336952c5061697da694db1cae97b116e9c46abcf7c8", - "sha256:78fa6da68ed2727915c4767bb386ab32cdba863caa7dbe473eaae45f9959da86", - "sha256:7ab8a544af125fb704feadb008c99a88805126fb525280b2270bb25cc1d78a12", - "sha256:99fcc3c8d804d1bc6d9a099921e39d827026409a58f2a720dcdb89374ea0c776", - "sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba", - "sha256:e89bf84b5437b532b0803ba5c9a5e054d21fec423a89952a74f87fa2c9b7bce2", - "sha256:fec3e9d8e36808a28efb59b489e4528c10ad0f480e57dcc32b4de5c9d8c9fdf3" - ], - "version": "==0.4.8" - }, - "pyasn1-modules": { - "hashes": [ - "sha256:0845a5582f6a02bb3e1bde9ecfc4bfcae6ec3210dd270522fee602365430c3f8", - "sha256:0fe1b68d1e486a1ed5473f1302bd991c1611d319bba158e98b106ff86e1d7199", - "sha256:15b7c67fabc7fc240d87fb9aabf999cf82311a6d6fb2c70d00d3d0604878c811", - "sha256:426edb7a5e8879f1ec54a1864f16b882c2837bfd06eee62f2c982315ee2473ed", - "sha256:65cebbaffc913f4fe9e4808735c95ea22d7a7775646ab690518c056784bc21b4", - "sha256:905f84c712230b2c592c19470d3ca8d552de726050d1d1716282a1f6146be65e", - "sha256:a50b808ffeb97cb3601dd25981f6b016cbb3d31fbf57a8b8a87428e6158d0c74", - "sha256:a99324196732f53093a84c4369c996713eb8c89d360a496b599fb1a9c47fc3eb", - "sha256:b80486a6c77252ea3a3e9b1e360bc9cf28eaac41263d173c032581ad2f20fe45", - "sha256:c29a5e5cc7a3f05926aff34e097e84f8589cd790ce0ed41b67aed6857b26aafd", - "sha256:cbac4bc38d117f2a49aeedec4407d23e8866ea4ac27ff2cf7fb3e5b570df19e0", - "sha256:f39edd8c4ecaa4556e989147ebf219227e2cd2e8a43c7e7fcb1f1c18c5fd6a3d", - "sha256:fe0644d9ab041506b62782e92b06b8c68cca799e1a9636ec398675459e031405" - ], - "version": "==0.2.8" - }, - "pycparser": { - "hashes": [ - "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0", - "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==2.20" - }, - "pyjwt": { - "hashes": [ - "sha256:5c6eca3c2940464d106b99ba83b00c6add741c9becaec087fb7ccdefea71350e", - "sha256:8d59a976fb773f3e6a39c85636357c4f0e242707394cadadd9814f5cbaa20e96" - ], - "version": "==1.7.1" - }, - "pyotp": { - "hashes": [ - "sha256:038a3f70b34eaad3f72459e8b411662ef8dfcdd95f7d9203fa489e987a75584b", - "sha256:ef07c393660529261e66902e788b32e46260d2c29eb740978df778260a1c2b4c" - ], - "version": "==2.4.1" - }, - "pyparsing": { - "hashes": [ - "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", - "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" - ], - "index": "pypi", - "version": "==2.4.7" - }, - "python-dateutil": { - "hashes": [ - "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c", - "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==2.8.1" - }, - "python-http-client": { - "hashes": [ - "sha256:0a5855902cede46775912d418a23f05fe6f5d60371df1084bef8c219218ce8d9", - "sha256:7e430f4b9dd2b621b0051f6a362f103447ea8e267594c602a5c502a0c694ee38", - "sha256:84267d8dcb7bcdf4c5cef321a533cc584c5b52159d4a4d3d4139bfed347b8006" - ], - "index": "pypi", - "version": "==3.1.0" - }, - "python3-openid": { - "hashes": [ - "sha256:33fbf6928f401e0b790151ed2b5290b02545e8775f982485205a066f874aaeaf", - "sha256:6626f771e0417486701e0b4daff762e7212e820ca5b29fcc0d05f6f8736dfa6b" - ], - "markers": "python_version >= '3.0'", - "version": "==3.2.0" - }, - "pytz": { - "hashes": [ - "sha256:3e6b7dd2d1e0a59084bcee14a17af60c5c562cdc16d828e8eba2e683d3a7e268", - "sha256:5c55e189b682d420be27c6995ba6edce0c0a77dd67bfbe2ae6607134d5851ffd" - ], - "version": "==2020.4" - }, - "requests": { - "hashes": [ - "sha256:7f1a0b932f4a60a1a65caa4263921bb7d9ee911957e0ae4a23a6dd08185ad5f8", - "sha256:e786fa28d8c9154e6a4de5d46a1d921b8749f8b74e28bde23768e5e16eece998" - ], - "index": "pypi", - "version": "==2.25.0" - }, - "requests-oauthlib": { - "hashes": [ - "sha256:7f71572defaecd16372f9006f33c2ec8c077c3cfa6f5911a9a90202beb513f3d", - "sha256:b4261601a71fd721a8bd6d7aa1cc1d6a8a93b4a9f5e96626f8e4d91e8beeaa6a", - "sha256:fa6c47b933f01060936d87ae9327fead68768b69c6c9ea2109c48be30f2d4dbc" - ], - "version": "==1.3.0" - }, - "rsa": { - "hashes": [ - "sha256:109ea5a66744dd859bf16fe904b8d8b627adafb9408753161e766a92e7d681fa", - "sha256:6166864e23d6b5195a5cfed6cd9fed0fe774e226d8f854fcb23b7bbef0350233" - ], - "markers": "python_version >= '3.5'", - "version": "==4.6" - }, - "ruamel.yaml": { - "hashes": [ - "sha256:012b9470a0ea06e4e44e99e7920277edf6b46eee0232a04487ea73a7386340a5", - "sha256:076cc0bc34f1966d920a49f18b52b6ad559fbe656a0748e3535cf7b3f29ebf9e" - ], - "version": "==0.16.12" - }, - "ruamel.yaml.clib": { - "hashes": [ - "sha256:058a1cc3df2a8aecc12f983a48bda99315cebf55a3b3a5463e37bb599b05727b", - "sha256:2602e91bd5c1b874d6f93d3086f9830f3e907c543c7672cf293a97c3fabdcd91", - "sha256:28116f204103cb3a108dfd37668f20abe6e3cafd0d3fd40dba126c732457b3cc", - "sha256:2d24bd98af676f4990c4d715bcdc2a60b19c56a3fb3a763164d2d8ca0e806ba7", - "sha256:30dca9bbcbb1cc858717438218d11eafb78666759e5094dd767468c0d577a7e7", - "sha256:44c7b0498c39f27795224438f1a6be6c5352f82cb887bc33d962c3a3acc00df6", - "sha256:464e66a04e740d754170be5e740657a3b3b6d2bcc567f0c3437879a6e6087ff6", - "sha256:4df5019e7783d14b79217ad9c56edf1ba7485d614ad5a385d1b3c768635c81c0", - "sha256:4e52c96ca66de04be42ea2278012a2342d89f5e82b4512fb6fb7134e377e2e62", - "sha256:5254af7d8bdf4d5484c089f929cb7f5bafa59b4f01d4f48adda4be41e6d29f99", - "sha256:52ae5739e4b5d6317b52f5b040b1b6639e8af68a5b8fd606a8b08658fbd0cab5", - "sha256:53b9dd1abd70e257a6e32f934ebc482dac5edb8c93e23deb663eac724c30b026", - "sha256:73b3d43e04cc4b228fa6fa5d796409ece6fcb53a6c270eb2048109cbcbc3b9c2", - "sha256:74161d827407f4db9072011adcfb825b5258a5ccb3d2cd518dd6c9edea9e30f1", - "sha256:839dd72545ef7ba78fd2aa1a5dd07b33696adf3e68fae7f31327161c1093001b", - "sha256:8e8fd0a22c9d92af3a34f91e8a2594eeb35cba90ab643c5e0e643567dc8be43e", - "sha256:a873e4d4954f865dcb60bdc4914af7eaae48fb56b60ed6daa1d6251c72f5337c", - "sha256:ab845f1f51f7eb750a78937be9f79baea4a42c7960f5a94dde34e69f3cce1988", - "sha256:b1e981fe1aff1fd11627f531524826a4dcc1f26c726235a52fcb62ded27d150f", - "sha256:b4b0d31f2052b3f9f9b5327024dc629a253a83d8649d4734ca7f35b60ec3e9e5", - "sha256:c6ac7e45367b1317e56f1461719c853fd6825226f45b835df7436bb04031fd8a", - "sha256:daf21aa33ee9b351f66deed30a3d450ab55c14242cfdfcd377798e2c0d25c9f1", - "sha256:e9f7d1d8c26a6a12c23421061f9022bb62704e38211fe375c645485f38df34a2", - "sha256:f6061a31880c1ed6b6ce341215336e2f3d0c1deccd84957b6fa8ca474b41e89f" - ], - "markers": "python_version < '3.9' and platform_python_implementation == 'CPython'", - "version": "==0.2.2" - }, - "rx": { - "hashes": [ - "sha256:0e0f2715a3452e95dcb5d6ea28ffe5742e832592bbcc67a48f394ef8ba871e6f", - "sha256:562851cfdb27fd5a218443cdbd682029684144dbafeb5dce34f6a709511282de" - ], - "markers": "python_full_version >= '3.6.0'", - "version": "==3.1.1" - }, - "s3transfer": { - "hashes": [ - "sha256:2482b4259524933a022d59da830f51bd746db62f047d6eb213f2f8855dcb8a13", - "sha256:921a37e2aefc64145e7b73d50c71bb4f26f46e4c9f414dc648c6245ff92cf7db" - ], - "version": "==0.3.3" - }, - "sendgrid": { - "hashes": [ - "sha256:9fba62068dd13922004b6a1676e21c6435709aaf7c2b978cdf1206e3d2196c60", - "sha256:d1af52f8cbb900bf79e28aa08102a503f5e26489c7b9f1d9750758a4b27c84d1" - ], - "version": "==3.6.5" - }, - "sendgrid-django": { - "hashes": [ - "sha256:fef60ba37d588e5e1c4dcbb3b8b7f8c16920e3e89432de357f6abd6157c4f5f4" - ], - "index": "pypi", - "version": "==4.2.0" - }, - "shortuuid": { - "hashes": [ - "sha256:3c11d2007b915c43bee3e10625f068d8a349e04f0d81f08f5fa08507427ebf1f", - "sha256:492c7402ff91beb1342a5898bd61ea953985bf24a41cd9f247409aa2e03c8f77" - ], - "index": "pypi", - "version": "==1.0.1" - }, - "six": { - "hashes": [ - "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", - "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" - ], - "index": "pypi", - "version": "==1.15.0" - }, - "smsapi-client": { - "hashes": [ - "sha256:24dbc95271643268fec3995ee2630165ac2abaa72795bef4945a6ed8f56f81da", - "sha256:81677a9fde0701557f0b8a8009912a817945ebd7e7e8cb8643dc426b7ec90974" - ], - "version": "==2.4.3" - }, - "social-auth-app-django": { - "hashes": [ - "sha256:2c69e57df0b30c9c1823519c5f1992cbe4f3f98fdc7d95c840e091a752708840", - "sha256:567ad0e028311541d7dfed51d3bf2c60440a6fd236d5d4d06c5a618b3d6c57c5", - "sha256:df5212370bd250108987c4748419a1a1d0cec750878856c2644c36aaa0fd3e58" - ], - "version": "==4.0.0" - }, - "social-auth-core": { - "hashes": [ - "sha256:21c0639c56befd33ec162c2210d583bb1de8e1136d53b21bafb96afaf2f86c91", - "sha256:2f6ce1af8ec2b2cc37b86d647f7d4e4292f091ee556941db34b1e0e2dee77fc0", - "sha256:4a3cdf69c449b235cdabd54a1be7ba3722611297e69fded52e3584b1a990af25" - ], - "version": "==3.3.3" - }, - "sqlparse": { - "hashes": [ - "sha256:017cde379adbd6a1f15a61873f43e8274179378e95ef3fede90b5aa64d304ed0", - "sha256:0f91fd2e829c44362cbcfab3e9ae12e22badaa8a29ad5ff599f9ec109f0454e8" - ], - "markers": "python_version >= '3.5'", - "version": "==0.4.1" - }, - "twilio": { - "hashes": [ - "sha256:effb4d6e9e9a9069065fbe21dea844597376ae6d6333626f14b05ba6b35bbb22" - ], - "version": "==6.47.0" - }, - "uritemplate": { - "hashes": [ - "sha256:07620c3f3f8eed1f12600845892b0e036a2420acf513c53f7de0abd911a5894f", - "sha256:5af8ad10cec94f215e3f48112de2022e1d5a37ed427fbd88652fa908f2ab7cae" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==3.0.1" - }, - "urllib3": { - "hashes": [ - "sha256:19188f96923873c92ccb987120ec4acaa12f0461fa9ce5d3d0772bc965a39e08", - "sha256:d8ff90d979214d7b4f8ce956e80f4028fc6860e4431f731ea4a8c08f23f99473" - ], - "markers": "python_version != '3.4'", - "version": "==1.26.2" - }, - "whitenoise": { - "hashes": [ - "sha256:15f43b2e701821b95c9016cf469d29e2a546cb1c7dead584ba82c36f843995cf", - "sha256:9d81515f2b5b27051910996e1e860b1332e354d9e7bcf30c98f21dcb6713e0dd" - ], - "index": "pypi", - "version": "==3.3.1" - }, - "yubico-client": { - "hashes": [ - "sha256:59d818661f638e3f041fae44ba2c0569e4eb2a17865fa7cc9ad6577185c4d185", - "sha256:e3b86cd2a123105edfacad40551c7b26e9c1193d81ffe168ee704ebfd3d11162" - ], - "version": "==1.13.0" - } - }, - "develop": { - "appdirs": { - "hashes": [ - "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41", - "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128" - ], - "index": "pypi", - "version": "==1.4.4" - }, - "astroid": { - "hashes": [ - "sha256:2f4078c2a41bf377eea06d71c9d2ba4eb8f6b1af2135bec27bbbb7d8f12bb703", - "sha256:bc58d83eb610252fd8de6363e39d4f1d0619c894b0ed24603b881c02e64c7386" - ], - "markers": "python_version >= '3.5'", - "version": "==2.4.2" - }, - "attrs": { - "hashes": [ - "sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6", - "sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==20.3.0" - }, - "autopep8": { - "hashes": [ - "sha256:d21d3901cb0da6ebd1e83fc9b0dfbde8b46afc2ede4fe32fbda0c7c6118ca094" - ], - "index": "pypi", - "version": "==1.5.4" - }, - "black": { - "hashes": [ - "sha256:1c02557aa099101b9d21496f8a914e9ed2222ef70336404eeeac8edba836fbea" - ], - "index": "pypi", - "version": "==20.8b1" - }, - "click": { - "hashes": [ - "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a", - "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", - "version": "==7.1.2" - }, - "django-test-migrations": { - "hashes": [ - "sha256:d120d0287e1dd82ed62fe083747a1e99c0398d56beda52594e8391b94a41bef5", - "sha256:e5747e2ad0b7e4d3b8d9ccd40d414b0f186316d3757af022b4bbdec700897521" - ], - "index": "pypi", - "version": "==1.0.0" - }, - "iniconfig": { - "hashes": [ - "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3", - "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32" - ], - "version": "==1.1.1" - }, - "isort": { - "hashes": [ - "sha256:dcab1d98b469a12a1a624ead220584391648790275560e1a43e54c5dceae65e7", - "sha256:dcaeec1b5f0eca77faea2a35ab790b4f3680ff75590bfcb7145986905aab2f58" - ], - "markers": "python_version >= '3.6' and python_version < '4.0'", - "version": "==5.6.4" - }, - "lazy-object-proxy": { - "hashes": [ - "sha256:0c4b206227a8097f05c4dbdd323c50edf81f15db3b8dc064d08c62d37e1a504d", - "sha256:194d092e6f246b906e8f70884e620e459fc54db3259e60cf69a4d66c3fda3449", - "sha256:1be7e4c9f96948003609aa6c974ae59830a6baecc5376c25c92d7d697e684c08", - "sha256:4677f594e474c91da97f489fea5b7daa17b5517190899cf213697e48d3902f5a", - "sha256:48dab84ebd4831077b150572aec802f303117c8cc5c871e182447281ebf3ac50", - "sha256:5541cada25cd173702dbd99f8e22434105456314462326f06dba3e180f203dfd", - "sha256:59f79fef100b09564bc2df42ea2d8d21a64fdcda64979c0fa3db7bdaabaf6239", - "sha256:8d859b89baf8ef7f8bc6b00aa20316483d67f0b1cbf422f5b4dc56701c8f2ffb", - "sha256:9254f4358b9b541e3441b007a0ea0764b9d056afdeafc1a5569eee1cc6c1b9ea", - "sha256:9651375199045a358eb6741df3e02a651e0330be090b3bc79f6d0de31a80ec3e", - "sha256:97bb5884f6f1cdce0099f86b907aa41c970c3c672ac8b9c8352789e103cf3156", - "sha256:9b15f3f4c0f35727d3a0fba4b770b3c4ebbb1fa907dbcc046a1d2799f3edd142", - "sha256:a2238e9d1bb71a56cd710611a1614d1194dc10a175c1e08d75e1a7bcc250d442", - "sha256:a6ae12d08c0bf9909ce12385803a543bfe99b95fe01e752536a60af2b7797c62", - "sha256:ca0a928a3ddbc5725be2dd1cf895ec0a254798915fb3a36af0964a0a4149e3db", - "sha256:cb2c7c57005a6804ab66f106ceb8482da55f5314b7fcb06551db1edae4ad1531", - "sha256:d74bb8693bf9cf75ac3b47a54d716bbb1a92648d5f781fc799347cfc95952383", - "sha256:d945239a5639b3ff35b70a88c5f2f491913eb94871780ebfabb2568bd58afc5a", - "sha256:eba7011090323c1dadf18b3b689845fd96a61ba0a1dfbd7f24b921398affc357", - "sha256:efa1909120ce98bbb3777e8b6f92237f5d5c8ea6758efea36a473e1d38f7d3e4", - "sha256:f3900e8a5de27447acbf900b4750b0ddfd7ec1ea7fbaf11dfa911141bc522af0" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.4.3" - }, - "mccabe": { - "hashes": [ - "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", - "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f" - ], - "version": "==0.6.1" - }, - "mypy-extensions": { - "hashes": [ - "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d", - "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8" - ], - "version": "==0.4.3" - }, - "packaging": { - "hashes": [ - "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8", - "sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181" - ], - "index": "pypi", - "version": "==20.4" - }, - "pathspec": { - "hashes": [ - "sha256:86379d6b86d75816baba717e64b1a3a3469deb93bb76d613c9ce79edc5cb68fd", - "sha256:aa0cb481c4041bf52ffa7b0d8fa6cd3e88a2ca4879c533c9153882ee2556790d" - ], - "version": "==0.8.1" - }, - "pep8": { - "hashes": [ - "sha256:b22cfae5db09833bb9bd7c8463b53e1a9c9b39f12e304a8d0bba729c501827ee", - "sha256:fe249b52e20498e59e0b5c5256aa52ee99fc295b26ec9eaa85776ffdb9fe6374" - ], - "index": "pypi", - "version": "==1.7.1" - }, - "pluggy": { - "hashes": [ - "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0", - "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==0.13.1" - }, - "py": { - "hashes": [ - "sha256:366389d1db726cd2fcfc79732e75410e5fe4d31db13692115529d34069a043c2", - "sha256:9ca6883ce56b4e8da7e79ac18787889fa5206c79dcc67fb065376cd2fe03f342" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.9.0" - }, - "pycodestyle": { - "hashes": [ - "sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367", - "sha256:c58a7d2815e0e8d7972bf1803331fb0152f867bd89adf8a01dfd55085434192e" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==2.6.0" - }, - "pylint": { - "hashes": [ - "sha256:bb4a908c9dadbc3aac18860550e870f58e1a02c9f2c204fdf5693d73be061210", - "sha256:bfe68f020f8a0fece830a22dd4d5dddb4ecc6137db04face4c3420a46a52239f" - ], - "index": "pypi", - "version": "==2.6.0" - }, - "pyparsing": { - "hashes": [ - "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", - "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" - ], - "index": "pypi", - "version": "==2.4.7" - }, - "pytest": { - "hashes": [ - "sha256:4288fed0d9153d9646bfcdf0c0428197dba1ecb27a33bb6e031d002fa88653fe", - "sha256:c0a7e94a8cdbc5422a51ccdad8e6f1024795939cc89159a0ae7f0b316ad3823e" - ], - "index": "pypi", - "version": "==6.1.2" - }, - "pytest-django": { - "hashes": [ - "sha256:10e384e6b8912ded92db64c58be8139d9ae23fb8361e5fc139d8e4f8fc601bc2", - "sha256:26f02c16d36fd4c8672390deebe3413678d89f30720c16efb8b2a6bf63b9041f" - ], - "index": "pypi", - "version": "==4.1.0" - }, - "regex": { - "hashes": [ - "sha256:02951b7dacb123d8ea6da44fe45ddd084aa6777d4b2454fa0da61d569c6fa538", - "sha256:0d08e71e70c0237883d0bef12cad5145b84c3705e9c6a588b2a9c7080e5af2a4", - "sha256:1862a9d9194fae76a7aaf0150d5f2a8ec1da89e8b55890b1786b8f88a0f619dc", - "sha256:1ab79fcb02b930de09c76d024d279686ec5d532eb814fd0ed1e0051eb8bd2daa", - "sha256:1fa7ee9c2a0e30405e21031d07d7ba8617bc590d391adfc2b7f1e8b99f46f444", - "sha256:262c6825b309e6485ec2493ffc7e62a13cf13fb2a8b6d212f72bd53ad34118f1", - "sha256:2a11a3e90bd9901d70a5b31d7dd85114755a581a5da3fc996abfefa48aee78af", - "sha256:2c99e97d388cd0a8d30f7c514d67887d8021541b875baf09791a3baad48bb4f8", - "sha256:3128e30d83f2e70b0bed9b2a34e92707d0877e460b402faca908c6667092ada9", - "sha256:38c8fd190db64f513fe4e1baa59fed086ae71fa45083b6936b52d34df8f86a88", - "sha256:3bddc701bdd1efa0d5264d2649588cbfda549b2899dc8d50417e47a82e1387ba", - "sha256:4902e6aa086cbb224241adbc2f06235927d5cdacffb2425c73e6570e8d862364", - "sha256:49cae022fa13f09be91b2c880e58e14b6da5d10639ed45ca69b85faf039f7a4e", - "sha256:56e01daca75eae420bce184edd8bb341c8eebb19dd3bce7266332258f9fb9dd7", - "sha256:5862975b45d451b6db51c2e654990c1820523a5b07100fc6903e9c86575202a0", - "sha256:6a8ce43923c518c24a2579fda49f093f1397dad5d18346211e46f134fc624e31", - "sha256:6c54ce4b5d61a7129bad5c5dc279e222afd00e721bf92f9ef09e4fae28755683", - "sha256:6e4b08c6f8daca7d8f07c8d24e4331ae7953333dbd09c648ed6ebd24db5a10ee", - "sha256:717881211f46de3ab130b58ec0908267961fadc06e44f974466d1887f865bd5b", - "sha256:749078d1eb89484db5f34b4012092ad14b327944ee7f1c4f74d6279a6e4d1884", - "sha256:7913bd25f4ab274ba37bc97ad0e21c31004224ccb02765ad984eef43e04acc6c", - "sha256:7a25fcbeae08f96a754b45bdc050e1fb94b95cab046bf56b016c25e9ab127b3e", - "sha256:83d6b356e116ca119db8e7c6fc2983289d87b27b3fac238cfe5dca529d884562", - "sha256:8b882a78c320478b12ff024e81dc7d43c1462aa4a3341c754ee65d857a521f85", - "sha256:8f6a2229e8ad946e36815f2a03386bb8353d4bde368fdf8ca5f0cb97264d3b5c", - "sha256:9801c4c1d9ae6a70aeb2128e5b4b68c45d4f0af0d1535500884d644fa9b768c6", - "sha256:a15f64ae3a027b64496a71ab1f722355e570c3fac5ba2801cafce846bf5af01d", - "sha256:a3d748383762e56337c39ab35c6ed4deb88df5326f97a38946ddd19028ecce6b", - "sha256:a63f1a07932c9686d2d416fb295ec2c01ab246e89b4d58e5fa468089cab44b70", - "sha256:b2b1a5ddae3677d89b686e5c625fc5547c6e492bd755b520de5332773a8af06b", - "sha256:b2f4007bff007c96a173e24dcda236e5e83bde4358a557f9ccf5e014439eae4b", - "sha256:baf378ba6151f6e272824b86a774326f692bc2ef4cc5ce8d5bc76e38c813a55f", - "sha256:bafb01b4688833e099d79e7efd23f99172f501a15c44f21ea2118681473fdba0", - "sha256:bba349276b126947b014e50ab3316c027cac1495992f10e5682dc677b3dfa0c5", - "sha256:c084582d4215593f2f1d28b65d2a2f3aceff8342aa85afd7be23a9cad74a0de5", - "sha256:d1ebb090a426db66dd80df8ca85adc4abfcbad8a7c2e9a5ec7513ede522e0a8f", - "sha256:d2d8ce12b7c12c87e41123997ebaf1a5767a5be3ec545f64675388970f415e2e", - "sha256:e32f5f3d1b1c663af7f9c4c1e72e6ffe9a78c03a31e149259f531e0fed826512", - "sha256:e3faaf10a0d1e8e23a9b51d1900b72e1635c2d5b0e1bea1c18022486a8e2e52d", - "sha256:f7d29a6fc4760300f86ae329e3b6ca28ea9c20823df123a2ea8693e967b29917", - "sha256:f8f295db00ef5f8bae530fc39af0b40486ca6068733fb860b42115052206466f" - ], - "version": "==2020.11.13" - }, - "six": { - "hashes": [ - "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", - "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" - ], - "index": "pypi", - "version": "==1.15.0" - }, - "toml": { - "hashes": [ - "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", - "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f" - ], - "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==0.10.2" - }, - "typed-ast": { - "hashes": [ - "sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355", - "sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919", - "sha256:0d8110d78a5736e16e26213114a38ca35cb15b6515d535413b090bd50951556d", - "sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa", - "sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652", - "sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75", - "sha256:3742b32cf1c6ef124d57f95be609c473d7ec4c14d0090e5a5e05a15269fb4d0c", - "sha256:4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01", - "sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d", - "sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1", - "sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907", - "sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c", - "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3", - "sha256:7e4c9d7658aaa1fc80018593abdf8598bf91325af6af5cce4ce7c73bc45ea53d", - "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b", - "sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614", - "sha256:92c325624e304ebf0e025d1224b77dd4e6393f18aab8d829b5b7e04afe9b7a2c", - "sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb", - "sha256:b52ccf7cfe4ce2a1064b18594381bccf4179c2ecf7f513134ec2f993dd4ab395", - "sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b", - "sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41", - "sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6", - "sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34", - "sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe", - "sha256:d648b8e3bf2fe648745c8ffcee3db3ff903d0817a01a12dd6a6ea7a8f4889072", - "sha256:f208eb7aff048f6bea9586e61af041ddf7f9ade7caed625742af423f6bae3298", - "sha256:fac11badff8313e23717f3dada86a15389d0708275bddf766cca67a84ead3e91", - "sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4", - "sha256:fcf135e17cc74dbfbc05894ebca928ffeb23d9790b3167a674921db19082401f", - "sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7" - ], - "version": "==1.4.1" - }, - "typing-extensions": { - "hashes": [ - "sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918", - "sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c", - "sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f" - ], - "version": "==3.7.4.3" - }, - "wrapt": { - "hashes": [ - "sha256:b62ffa81fb85f4332a4f609cab4ac40709470da05643a082ec1eb88e6d9b97d7" - ], - "version": "==1.12.1" - } - } -} diff --git a/bin/docker b/bin/docker index 6e97d6d4e46d..ed3c72ef63ac 100755 --- a/bin/docker +++ b/bin/docker @@ -1,6 +1,6 @@ #!/bin/bash set -e -.venv/bin/python src/manage.py migrate -.venv/bin/python src/manage.py collectstatic --no-input -.venv/bin/gunicorn --bind 0.0.0.0:8000 -w ${GUNICORN_WORKERS:-3} -w ${GUNICORN_THREADS:-2} --pythonpath src app.wsgi +python src/manage.py migrate +python src/manage.py collectstatic --no-input +gunicorn --bind 0.0.0.0:8000 -w ${GUNICORN_WORKERS:-3} -w ${GUNICORN_THREADS:-2} --pythonpath src app.wsgi diff --git a/bin/docker-dev b/bin/docker-dev index 27d9d36099b8..bd6440e6ff3f 100755 --- a/bin/docker-dev +++ b/bin/docker-dev @@ -1,6 +1,6 @@ #!/bin/bash set -e -pipenv run python src/manage.py migrate -pipenv run python src/manage.py collectstatic --no-input -pipenv run python src/manage.py runserver 0.0.0.0:8000 +python src/manage.py migrate +python src/manage.py collectstatic --no-input +python src/manage.py runserver 0.0.0.0:8000 diff --git a/docker-compose.yml b/docker-compose.yml index 14697b330d77..229046aaa439 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,9 +9,9 @@ services: build: context: . dockerfile: docker/Dockerfile - 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" + command: bash -c "python manage.py migrate --noinput + && python manage.py collectstatic --noinput + && gunicorn --bind 0.0.0.0:8000 -w 3 app.wsgi" environment: DJANGO_DB_NAME: bullettrain DJANGO_DB_USER: postgres diff --git a/readme.md b/readme.md index 5e18cf4735c5..0a9abf368e64 100644 --- a/readme.md +++ b/readme.md @@ -9,18 +9,19 @@ Before running the application, you'll need to configure a database for the application. The steps to do this can be found in the following section entitled 'Databases'. -``` -pip install pipenv -pipenv install -pipenv run python src/manage.py migrate -pipenv run python src/manage.py runserver +```bash +virtualenv .venv +source .venv/bin/activate +pip install -r requirements-dev.txt +python src/manage.py migrate +python src/manage.py runserver ``` The application can also be run locally using Docker Compose if required, however, it's beneficial to run locally using the above steps as it gives you hot reloading. To run using docker compose, -simply run the following command from the project root: +simply run the following command from the project root: -``` +```bash docker-compose up ``` @@ -28,21 +29,22 @@ docker-compose up We are slowly migrating the code style to use [black](https://github.com/psf/black) as a formatter. Black automatically formats the code for you, you can run the formatter -by running: +by running: -``` +```bash python -m black path/to/directory/or/file.py ``` -All new code should adhere to black formatting standards. +All new code should adhere to black formatting standards. ## Databases + Databases are configured in app/settings/\.py -The app is configured to use PostgreSQL for all environments. +The app is configured to use PostgreSQL for all environments. When running locally, you'll need a local instance of postgres running. The easiest way to do this -is to use docker which is achievable with the following command: +is to use docker which is achievable with the following command: ```docker run --name local_postgres -d -P postgres``` @@ -62,10 +64,10 @@ located in `app.settings.master-docker` The application is built using django which comes with a handy set of admin pages available at `/admin/`. To access these, you'll need to create a super user. This can be done with the following -command: +command: -``` -pipenv run python src/manage.py createsuperuser +```bash +python src/manage.py createsuperuser ``` Once you've created the super user, you can use the details to log in at `/admin/`. From here, you @@ -90,13 +92,14 @@ Note that this functionality can be turned off in the settings if required by se ## Deploying ### Using Heroku-ish Platform (e.g. Heroku, Dokku, Flynn) + The application should run on any Heroku-ish platform (e.g. Dokku, Flynn) by simply adding the required git repo and pushing the code. The code for running the app is contained in the Procfile. To get it running, you'll need to add the necessary config variables as outlined below. - ### Using ElasticBeanstalk + The application will run within ElasticBeanstalk using the default Python setup. We've included the .ebextensions/ and .elasticbeanstalk/ directories which will run on ElasticBeanstalk. @@ -106,19 +109,20 @@ The changes required to run in your environment will be as follows `.ebextensions/options.config` - within the root of the project `generate.sh` will add in all environment variables that are required using your chosen CI/CD. Alternatively, you can add your own `options.config`. - ### Using Docker + The application can be configured to run using docker with simply by running the following command: -``` +```bash docker-compose up -``` +``` This will use some default settings created in the `docker-compose.yml` file located in the root of the project. These should be changed before using in any production environments. ### Environment Variables -The application relies on the following environment variables to run: + +The application relies on the following environment variables to run: * `ENV`: string representing the current running environment, e.g. 'local', 'dev', 'prod'. Defaults to 'local' * `DJANGO_ALLOWED_HOSTS`: comma separated list of hosts the application will run on in the given environment @@ -144,34 +148,30 @@ The application relies on the following environment variables to run: * `ENABLE_EMAIL_ACTIVATION`: new user registration will go via email activation flow, default False ### Creating a secret key + It is important to also set an environment variable on whatever platform you are using for `DJANGO_SECRET_KEY`. There is a function to create one in `app.settings.common` if none exists in the environment variables, however, this is not suitable for use in production. To generate a new secret key, you can use the function defined in `src/secret-key-gen.py` by simply running it from a -command prompt: +command prompt: +```bash +python secret-key-gen.py ``` -python secret-key-gen.py -``` ## Adding dependencies -To add a python dependency, run the following commands: -``` -pipenv install -``` - -The dependency then needs to be added to the relevant requirements*.txt files as necessary. +To add a python dependency, add it to requirements.txt / requirements-dev.txt with it's current version number. ## Caching -The application makes use of caching in a couple of locations: +The application makes use of caching in a couple of locations: 1. Environment authentication - the application utilises an in memory cache for the environment object on all endpoints that use the X-Environment-Key header. 2. Environment flags - the application utilises an in memory cache for the flags returned when calling /flags. The number of seconds this is cached for is configurable using the environment variable -`"CACHE_FLAGS_SECONDS"` +`"CACHE_FLAGS_SECONDS"` 3. Project Segments - the application utilises an in memory cache for returning the segments for a given project. The number of seconds this is cached for is configurable using the environment variable `"CACHE_PROJECT_SEGMENTS_SECONDS"`. @@ -180,9 +180,10 @@ given project. The number of seconds this is cached for is configurable using th - Python 2.7.14 - Django 1.11.13 -- DjangoRestFramework 3.8.2 +- DjangoRestFramework 3.8.2 ## Static Files + Although the application relies on very few static files, it is possible to optimise their configuration to host these static files in S3. This is done using the relevant environment variables provided above. Note, however, that in order to use the configuration, the environment that you are hosting on must have the correct AWS credentials @@ -192,7 +193,7 @@ that the static files are hosted in. ## Documentation -Further documentation can be found [here](https://docs.bullet-train.io). +Further documentation can be found [here](https://docs.bullet-train.io). ## Contributing diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 000000000000..a8fb5cdd28da --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,8 @@ +-r requirements.txt +pylint +pep8 +autopep8 +pytest +pytest-django +django-test-migrations +black \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 000000000000..90c96e689da0 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,34 @@ +appdirs==1.4.4 +django-cors-headers==3.5.0 +djangorestframework==3.12.1 +gunicorn==20.0.4 +pyparsing==2.4.7 +requests==2.24.0 +six==1.15.0 +whitenoise<4.0 +dj-database-url==0.5.0 +drf-nested-routers==0.92.1 +shortuuid==1.0.1 +sendgrid-django==4.2.0 +psycopg2-binary==2.8.6 +coreapi==2.3.3 +Django<3.0 +django-simple-history==2.12.0 +google-api-python-client==1.12.5 +oauth2client==4.1.3 +djangorestframework-recursive==0.1.2 +packaging==20.4 +chargebee==2.7.7 +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==3.14.3 +django-storages==1.10.1 +django-environ==0.4.5 +django-trench==0.2.3 +djoser==2.0.5 +influxdb-client==1.11.0 +django-ordered-model==3.4.1 +django-ses==1.0.3 +django-axes==5.8.0 +django-admin-sso==3.0.0 +drf-yasg2==1.19.3 +django-debug-toolbar==3.1.1 \ No newline at end of file diff --git a/src/app/settings/common.py b/src/app/settings/common.py index e78ec2b065c8..42a6a69832df 100644 --- a/src/app/settings/common.py +++ b/src/app/settings/common.py @@ -102,7 +102,6 @@ 'segments', 'e2etests', 'simple_history', - 'debug_toolbar', 'drf_yasg2', 'audit', 'permissions', @@ -164,7 +163,6 @@ 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', 'simple_history.middleware.HistoryRequestMiddleware', - 'debug_toolbar.middleware.DebugToolbarMiddleware', 'app.middleware.AxesMiddleware', ] diff --git a/src/app/settings/local.py b/src/app/settings/local.py index 28e59f8d5967..da008dd0b985 100644 --- a/src/app/settings/local.py +++ b/src/app/settings/local.py @@ -4,6 +4,10 @@ ALLOWED_HOSTS.extend(['.ngrok.io', '127.0.0.1', 'localhost']) +INSTALLED_APPS.extend(['debug_toolbar']) + +MIDDLEWARE.extend(['debug_toolbar.middleware.DebugToolbarMiddleware']) + DEBUG = True DATABASES = { diff --git a/src/organisations/tests/test_views.py b/src/organisations/tests/test_views.py index ecc31aa3a8da..2dddf6716233 100644 --- a/src/organisations/tests/test_views.py +++ b/src/organisations/tests/test_views.py @@ -1,6 +1,7 @@ import json from datetime import datetime, timedelta from unittest import TestCase, mock +from django.test.utils import override_settings import pytest from django.conf import settings @@ -9,7 +10,7 @@ from django.urls import reverse from pytz import UTC from rest_framework import status -from rest_framework.test import APIClient +from rest_framework.test import APIClient, override_settings from environments.models import Environment from features.models import Feature, FeatureSegment @@ -250,6 +251,7 @@ def test_should_get_usage_for_organisation(self, mock_influxdb_client): assert response.status_code == status.HTTP_200_OK mock_influxdb_client.query_api.return_value.query.assert_called_once_with(org=influx_org, query=query) + @override_settings(ENABLE_CHARGEBEE=True) @mock.patch('organisations.serializers.get_subscription_data_from_hosted_page') def test_update_subscription_gets_subscription_data_from_chargebee(self, mock_get_subscription_data): # Given diff --git a/version.txt b/version.txt index e3a4f193364d..276cbf9e2858 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -2.2.0 \ No newline at end of file +2.3.0 From 3a2e7320dcc9deaa2bab1affc65330c5eb17b654 Mon Sep 17 00:00:00 2001 From: Mateusz Szlendak Date: Thu, 19 Nov 2020 15:48:37 +0000 Subject: [PATCH 18/47] Update views.py --- src/sales_dashboard/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sales_dashboard/views.py b/src/sales_dashboard/views.py index 7580280129df..3695c8d0fd8b 100644 --- a/src/sales_dashboard/views.py +++ b/src/sales_dashboard/views.py @@ -10,7 +10,7 @@ from organisations.models import Organisation from django.shortcuts import get_object_or_404 from django.views.generic import ListView -OBJECTS_PER_PAGE = 200 +OBJECTS_PER_PAGE = 50 class OrganisationList(ListView): From 4cf8a0b62c9347fe77be1e5b25084a5f1dc53624 Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Sat, 21 Nov 2020 14:34:20 +0000 Subject: [PATCH 19/47] Remove unnecessary secret_key_gen util function --- readme.md | 13 +------------ src/app/settings/common.py | 8 +++----- src/app/utils.py | 10 ---------- src/secret-key-gen.py | 4 ---- 4 files changed, 4 insertions(+), 31 deletions(-) delete mode 100644 src/secret-key-gen.py diff --git a/readme.md b/readme.md index 0a9abf368e64..5326d89dfa36 100644 --- a/readme.md +++ b/readme.md @@ -128,6 +128,7 @@ The application relies on the following environment variables to run: * `DJANGO_ALLOWED_HOSTS`: comma separated list of hosts the application will run on in the given environment * `DJANGO_CSRF_TRUSTED_ORIGINS`: comma separated list of hosts to allow unsafe (POST, PUT) requests from. Useful for allowing localhost to set traits in development. * `DJANGO_SETTINGS_MODULE`: python path to settings file for the given environment, e.g. "app.settings.develop" +* `DJANGO_SECRET_KEY`: secret key required by Django, if one isn't provided one will be created using `django.core.management.utilsget_random_secret_key` * `EMAIL_BACKEND`: email provider. Allowed values are `sgbackend.SendGridBackend` for Sendgrid or `django_ses.SESBackend` for Amazon SES. Defaults to `sgbackend.SendGridBackend`. * `SENDGRID_API_KEY`: API key for the Sendgrid account * `SENDER_EMAIL`: Email address from which emails are sent @@ -147,18 +148,6 @@ The application relies on the following environment variables to run: * `USER_CREATE_PERMISSIONS`: set the permissions for creating new users, using a comma separated list of djoser or rest_framework permissions. Use this to turn off public user creation for self hosting. e.g. `'djoser.permissions.CurrentUserOrAdmin'` Defaults to `'rest_framework.permissions.AllowAny'`. * `ENABLE_EMAIL_ACTIVATION`: new user registration will go via email activation flow, default False -### Creating a secret key - -It is important to also set an environment variable on whatever platform you are using for -`DJANGO_SECRET_KEY`. There is a function to create one in `app.settings.common` if none exists in -the environment variables, however, this is not suitable for use in production. To generate a new -secret key, you can use the function defined in `src/secret-key-gen.py` by simply running it from a -command prompt: - -```bash -python secret-key-gen.py -``` - ## Adding dependencies To add a python dependency, add it to requirements.txt / requirements-dev.txt with it's current version number. diff --git a/src/app/settings/common.py b/src/app/settings/common.py index 42a6a69832df..9bfd6768f1be 100644 --- a/src/app/settings/common.py +++ b/src/app/settings/common.py @@ -20,7 +20,8 @@ from corsheaders.defaults import default_headers from datetime import timedelta -from app.utils import secret_key_gen +from django.core.management.utils import get_random_secret_key + env = environ.Env() @@ -32,10 +33,7 @@ if ENV not in ('local', 'dev', 'staging', 'production'): warnings.warn('ENVIRONMENT env variable must be one of local, dev, staging or production') -if 'DJANGO_SECRET_KEY' not in os.environ: - secret_key_gen() - -SECRET_KEY = os.environ['DJANGO_SECRET_KEY'] +SECRET_KEY = env('DJANGO_SECRET_KEY', default=get_random_secret_key()) HOSTED_SEATS_LIMIT = int(os.environ.get('HOSTED_SEATS_LIMIT', 0)) diff --git a/src/app/utils.py b/src/app/utils.py index 7b7c69b6442c..38aae2b99005 100644 --- a/src/app/utils.py +++ b/src/app/utils.py @@ -1,16 +1,6 @@ -import os -import random - import shortuuid def create_hash(): """Helper function to create a short hash""" return shortuuid.uuid() - -def secret_key_gen(): - secret_key = ''.join(random.SystemRandom() - .choice('abcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*(-_=+)') - for i in range(50)) - os.environ['DJANGO_SECRET_KEY'] = secret_key - return secret_key \ No newline at end of file diff --git a/src/secret-key-gen.py b/src/secret-key-gen.py deleted file mode 100644 index 70c5c72547cd..000000000000 --- a/src/secret-key-gen.py +++ /dev/null @@ -1,4 +0,0 @@ -from app.utils import secret_key_gen - - -print(secret_key_gen()) From 5fd139ecb5b3daeafafe01912a191c0e806cf558 Mon Sep 17 00:00:00 2001 From: Maciej Krol Date: Sat, 21 Nov 2020 14:41:23 +0000 Subject: [PATCH 20/47] Feature/ch996/black formatting --- .gitlab-ci.yml | 1 + .isort.cfg | 7 + .pre-commit-config.yaml | 14 + src/analytics/influxdb_wrapper.py | 46 +- src/analytics/query.py | 34 +- src/analytics/tests/test_influxdb_wrapper.py | 69 +- src/analytics/tests/test_unit_track.py | 31 +- src/analytics/track.py | 46 +- src/analytics/views.py | 4 +- src/api/apps.py | 2 +- src/api/serializers.py | 2 +- src/api/urls/deprecated.py | 13 +- src/api/urls/v1.py | 54 +- src/app/handlers.py | 4 +- src/app/middleware.py | 21 +- src/app/settings/common.py | 434 ++++++----- src/app/settings/develop.py | 41 +- src/app/settings/local.py | 24 +- src/app/settings/master-docker.py | 55 +- src/app/settings/master.py | 45 +- src/app/settings/staging.py | 45 +- src/app/settings/test.py | 5 +- src/app/tests/test_middleware.py | 40 +- src/app/tests/test_urls.py | 4 +- src/app/urls.py | 25 +- src/audit/__init__.py | 2 +- src/audit/apps.py | 2 +- src/audit/models.py | 44 +- src/audit/serializers.py | 10 +- src/audit/signals.py | 48 +- src/audit/tests/test_models.py | 79 +- src/audit/urls.py | 8 +- src/audit/views.py | 41 +- src/custom_auth/mfa/backends/application.py | 5 +- src/custom_auth/oauth/github.py | 36 +- src/custom_auth/oauth/google.py | 2 +- .../helpers/tests/test_unit_github_helpers.py | 5 +- src/custom_auth/oauth/serializers.py | 2 +- .../oauth/tests/test_unit_github.py | 68 +- .../oauth/tests/test_unit_google.py | 6 +- .../oauth/tests/test_unit_serializers.py | 12 +- src/custom_auth/oauth/urls.py | 7 +- src/custom_auth/permissions.py | 1 + src/custom_auth/serializers.py | 4 +- .../test_custom_auth_integration.py | 78 +- src/custom_auth/urls.py | 17 +- src/custom_auth/views.py | 3 +- .../end_to_end/test_integration_e2e_tests.py | 2 +- src/e2etests/urls.py | 4 +- src/e2etests/views.py | 16 +- src/environments/__init__.py | 2 +- src/environments/admin.py | 21 +- src/environments/apps.py | 2 +- src/environments/authentication.py | 7 +- src/environments/exceptions.py | 2 - .../identities/tests/test_models.py | 56 +- .../identities/tests/test_views.py | 32 +- .../identities/traits/tests/test_views.py | 50 +- src/environments/identities/traits/views.py | 6 +- src/environments/identities/views.py | 17 +- src/environments/models.py | 37 +- src/environments/sdk/serializers.py | 46 +- src/environments/serializers.py | 43 +- src/environments/tests/test_authentication.py | 30 +- src/environments/tests/test_models.py | 14 +- src/environments/tests/test_views.py | 397 ++++++---- src/environments/urls.py | 61 +- src/environments/views.py | 161 +++-- src/features/__init__.py | 2 +- src/features/admin.py | 91 ++- src/features/apps.py | 2 +- src/features/fields.py | 4 +- src/features/helpers.py | 5 +- src/features/models.py | 252 ++++--- src/features/permissions.py | 26 +- src/features/serializers.py | 146 ++-- src/features/signals.py | 18 +- src/features/tasks.py | 2 +- src/features/tests/test_fields.py | 21 +- src/features/tests/test_migrations.py | 122 ++-- src/features/tests/test_models.py | 381 +++++++--- src/features/tests/test_permissions.py | 200 ++++-- src/features/tests/test_tasks.py | 17 +- src/features/tests/test_utils.py | 11 +- src/features/tests/test_views.py | 677 ++++++++++++------ src/features/urls.py | 12 +- src/features/utils.py | 4 +- src/features/views.py | 303 +++++--- src/integrations/amplitude/__init__.py | 2 +- src/integrations/amplitude/amplitude.py | 9 +- src/integrations/amplitude/apps.py | 2 +- src/integrations/amplitude/models.py | 6 +- src/integrations/amplitude/serializers.py | 2 +- .../amplitude/tests/test_amplitude.py | 25 +- .../amplitude/tests/test_views.py | 56 +- src/integrations/amplitude/views.py | 13 +- src/integrations/datadog/__init__.py | 2 +- src/integrations/datadog/apps.py | 2 +- src/integrations/datadog/datadog.py | 7 +- src/integrations/datadog/models.py | 4 +- src/integrations/datadog/serializers.py | 2 +- .../datadog/tests/test_datadog.py | 46 +- src/integrations/datadog/tests/test_views.py | 48 +- src/integrations/datadog/views.py | 10 +- src/organisations/__init__.py | 2 +- src/organisations/admin.py | 15 +- src/organisations/apps.py | 2 +- src/organisations/chargebee.py | 37 +- .../check_if_organisations_over_plan_limit.py | 13 +- src/organisations/models.py | 59 +- src/organisations/permissions.py | 32 +- src/organisations/serializers.py | 120 ++-- src/organisations/signals.py | 17 +- src/organisations/tests/test_chargebee.py | 134 ++-- src/organisations/tests/test_models.py | 8 +- src/organisations/tests/test_permissions.py | 14 +- src/organisations/tests/test_views.py | 359 ++++++---- src/organisations/urls.py | 29 +- src/organisations/views.py | 110 ++- src/permissions/serializers.py | 18 +- src/projects/admin.py | 28 +- src/projects/apps.py | 2 +- src/projects/models.py | 45 +- src/projects/permissions.py | 10 +- src/projects/serializers.py | 29 +- src/projects/tags/admin.py | 11 +- src/projects/tags/apps.py | 2 +- src/projects/tags/models.py | 9 +- src/projects/tags/permissions.py | 12 +- src/projects/tags/serializers.py | 7 +- src/projects/tags/tests/test_models.py | 17 +- src/projects/tags/tests/test_permissions.py | 40 +- src/projects/tags/views.py | 6 +- src/projects/tests/conftest.py | 8 +- src/projects/tests/test_models.py | 10 +- src/projects/tests/test_permissions.py | 172 +++-- src/projects/tests/test_views.py | 216 +++--- src/projects/urls.py | 41 +- src/projects/views.py | 120 +++- src/sales_dashboard/apps.py | 2 +- src/sales_dashboard/urls.py | 9 +- src/sales_dashboard/views.py | 67 +- src/segments/admin.py | 12 +- src/segments/apps.py | 2 +- src/segments/models.py | 88 ++- src/segments/permissions.py | 16 +- src/segments/serializers.py | 60 +- src/segments/tests/test_models.py | 120 +++- src/segments/tests/test_permissions.py | 67 +- src/segments/tests/test_views.py | 156 ++-- src/segments/views.py | 44 +- src/users/admin.py | 78 +- src/users/apps.py | 2 +- src/users/emails.py | 4 +- src/users/models.py | 244 ++++--- src/users/serializers.py | 44 +- src/users/tests/test_models.py | 67 +- src/users/tests/test_views.py | 234 +++--- src/users/urls.py | 6 +- src/users/views.py | 98 ++- src/util/history/custom_simple_history.py | 1 + src/util/logging.py | 2 +- src/util/tests.py | 20 +- src/util/util.py | 3 +- src/util/views.py | 2 +- src/webhooks/exceptions.py | 2 +- src/webhooks/serializers.py | 1 - src/webhooks/tests/test_webhooks.py | 42 +- src/webhooks/webhooks.py | 44 +- 169 files changed, 5249 insertions(+), 3126 deletions(-) create mode 100644 .isort.cfg create mode 100644 .pre-commit-config.yaml diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 8232d437bc4b..5052df942fc7 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -15,6 +15,7 @@ test: POSTGRES_PASSWORD: testpass script: - pip install -r requirements-dev.txt + - black --check . - pytest src -p no:warnings deploydevelop: diff --git a/.isort.cfg b/.isort.cfg new file mode 100644 index 000000000000..e5df85b4af87 --- /dev/null +++ b/.isort.cfg @@ -0,0 +1,7 @@ +[settings] +use_parentheses=true +multi_line_output=3 +include_trailing_comma=true +line_length=79 +known_first_party=analytics,app,custom_auth,environments,integrations,organisations,projects,segments,users,webhooks,api,audit,e2etests,features,permissions,util +known_third_party=apiclient,axes,chargebee,coreapi,corsheaders,dj_database_url,django,djoser,drf_yasg2,environ,google,influxdb_client,ordered_model,pyotp,pytest,pytz,requests,rest_framework,rest_framework_nested,rest_framework_recursive,shortuuid,simple_history,six,trench,whitenoise diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 000000000000..03ab5e96e863 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,14 @@ +repos: + - repo: https://github.com/asottile/seed-isort-config + rev: v1.9.3 + hooks: + - id: seed-isort-config + - repo: https://github.com/pre-commit/mirrors-isort + rev: v4.3.21 + hooks: + - id: isort + - repo: https://github.com/psf/black + rev: stable + hooks: + - id: black + language_version: python3 diff --git a/src/analytics/influxdb_wrapper.py b/src/analytics/influxdb_wrapper.py index 7b36af880050..561b5fb5f61f 100644 --- a/src/analytics/influxdb_wrapper.py +++ b/src/analytics/influxdb_wrapper.py @@ -15,11 +15,11 @@ def __init__(self, name): self.name = name self.records = [] self.write_api = influxdb_client.write_api(write_options=SYNCHRONOUS) - + def add_data_point(self, field_name, field_value, tags=None): point = Point(self.name) point.field(field_name, field_value) - + if tags is not None: for tag_key, tag_value in tags.items(): point = point.tag(tag_key, tag_value) @@ -29,22 +29,23 @@ def add_data_point(self, field_name, field_value, tags=None): def write(self): self.write_api.write(bucket=settings.INFLUXDB_BUCKET, record=self.records) - @staticmethod def influx_query_manager( - date_range: str = "30d", - date_stop: str = "now()", - drop_columns: str = "'organisation', 'organisation_id', 'type', 'project', 'project_id'", - filters: str = "|> filter(fn:(r) => r._measurement == 'api_call')", - extra: str = "" + date_range: str = "30d", + date_stop: str = "now()", + drop_columns: str = "'organisation', 'organisation_id', 'type', 'project', 'project_id'", + filters: str = "|> filter(fn:(r) => r._measurement == 'api_call')", + extra: str = "", ): query_api = influxdb_client.query_api() - query = f'from(bucket:"{read_bucket}")' \ - f' |> range(start: -{date_range}, stop: {date_stop})' \ - f' {filters}' \ - f' |> drop(columns: [{drop_columns}])' \ - f'{extra}' + query = ( + f'from(bucket:"{read_bucket}")' + f" |> range(start: -{date_range}, stop: {date_stop})" + f" {filters}" + f" |> drop(columns: [{drop_columns}])" + f"{extra}" + ) result = query_api.query(org=influx_org, query=query) return result @@ -62,7 +63,7 @@ def get_events_for_organisation(organisation_id): |> filter(fn: (r) => r["_field"] == "request_count") \ |> filter(fn: (r) => r["organisation_id"] == "{organisation_id}")', drop_columns='"organisation", "project", "project_id"', - extra="|> sum()" + extra="|> sum()", ) total = 0 @@ -85,17 +86,16 @@ def get_event_list_for_organisation(organisation_id: int): filters=f'|> filter(fn:(r) => r._measurement == "api_call") \ |> filter(fn: (r) => r["organisation_id"] == "{organisation_id}")', drop_columns='"organisation", "organisation_id", "type", "project", "project_id"', - extra="|> aggregateWindow(every: 24h, fn: sum)" + extra="|> aggregateWindow(every: 24h, fn: sum)", ) dataset = [] labels = [] for result in results: for record in result.records: - dataset.append({ - 't': record.values['_time'].isoformat(), - 'y': record.values['_value'] - }) - labels.append(record.values['_time'].strftime('%Y-%m-%d')) + dataset.append( + {"t": record.values["_time"].isoformat(), "y": record.values["_value"]} + ) + labels.append(record.values["_time"].strftime("%Y-%m-%d")) return dataset, labels @@ -111,7 +111,7 @@ def get_multiple_event_list_for_organisation(organisation_id: int): filters=f'|> filter(fn:(r) => r._measurement == "api_call") \ |> filter(fn: (r) => r["organisation_id"] == "{organisation_id}")', drop_columns='"organisation", "organisation_id", "type", "project", "project_id"', - extra="|> aggregateWindow(every: 24h, fn: sum)" + extra="|> aggregateWindow(every: 24h, fn: sum)", ) if not results: return results @@ -120,6 +120,6 @@ def get_multiple_event_list_for_organisation(organisation_id: int): for result in results: for i, record in enumerate(result.records): - dataset[i][record.values['resource']] = record.values['_value'] - dataset[i]['name'] = record.values['_time'].isoformat() + dataset[i][record.values["resource"]] = record.values["_value"] + dataset[i]["name"] = record.values["_time"].isoformat() return dataset diff --git a/src/analytics/query.py b/src/analytics/query.py index 8014f6f972b1..a7714b8c32c3 100644 --- a/src/analytics/query.py +++ b/src/analytics/query.py @@ -4,9 +4,9 @@ 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' +GA_SCOPES = ["https://www.googleapis.com/auth/analytics.readonly"] +GA_API_NAME = "analytics" +GA_API_VERSION = "v3" def get_service(): @@ -14,7 +14,8 @@ 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) + json.loads(settings.GOOGLE_SERVICE_ACCOUNT), scopes=GA_SCOPES + ) # Build the service object. return build(GA_API_NAME, GA_API_VERSION, credentials=credentials) @@ -26,12 +27,19 @@ def get_events_for_organisation(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']) + 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"]) diff --git a/src/analytics/tests/test_influxdb_wrapper.py b/src/analytics/tests/test_influxdb_wrapper.py index e1993a59492d..765656812fb7 100644 --- a/src/analytics/tests/test_influxdb_wrapper.py +++ b/src/analytics/tests/test_influxdb_wrapper.py @@ -4,7 +4,11 @@ import analytics from analytics.influxdb_wrapper import InfluxDBWrapper -from analytics.influxdb_wrapper import get_events_for_organisation, get_event_list_for_organisation, get_multiple_event_list_for_organisation +from analytics.influxdb_wrapper import ( + get_events_for_organisation, + get_event_list_for_organisation, + get_multiple_event_list_for_organisation, +) # Given org_id = 123 @@ -15,7 +19,9 @@ def test_write(monkeypatch): # Given mock_influxdb_client = mock.MagicMock() - monkeypatch.setattr(analytics.influxdb_wrapper, "influxdb_client", mock_influxdb_client) + monkeypatch.setattr( + analytics.influxdb_wrapper, "influxdb_client", mock_influxdb_client + ) mock_write_api = mock.MagicMock() mock_influxdb_client.write_api.return_value = mock_write_api @@ -31,14 +37,18 @@ def test_write(monkeypatch): def test_influx_db_query_when_get_events_then_query_api_called(monkeypatch): - query = f'from(bucket:"{read_bucket}") |> range(start: -30d, stop: now()) ' \ - f'|> filter(fn:(r) => r._measurement == "api_call") ' \ - f'|> filter(fn: (r) => r["_field"] == "request_count") ' \ - f'|> filter(fn: (r) => r["organisation_id"] == "{org_id}") ' \ - f'|> drop(columns: ["organisation", "project", "project_id"])' \ - f'|> sum()' + query = ( + f'from(bucket:"{read_bucket}") |> range(start: -30d, stop: now()) ' + f'|> filter(fn:(r) => r._measurement == "api_call") ' + f'|> filter(fn: (r) => r["_field"] == "request_count") ' + f'|> filter(fn: (r) => r["organisation_id"] == "{org_id}") ' + f'|> drop(columns: ["organisation", "project", "project_id"])' + f"|> sum()" + ) mock_influxdb_client = mock.MagicMock() - monkeypatch.setattr(analytics.influxdb_wrapper, "influxdb_client", mock_influxdb_client) + monkeypatch.setattr( + analytics.influxdb_wrapper, "influxdb_client", mock_influxdb_client + ) mock_query_api = mock.MagicMock() mock_influxdb_client.query_api.return_value = mock_query_api @@ -51,14 +61,18 @@ def test_influx_db_query_when_get_events_then_query_api_called(monkeypatch): def test_influx_db_query_when_get_events_list_then_query_api_called(monkeypatch): - query = f'from(bucket:"{read_bucket}") ' \ - f'|> range(start: -30d, stop: now()) ' \ - f'|> filter(fn:(r) => r._measurement == "api_call") ' \ - f'|> filter(fn: (r) => r["organisation_id"] == "{org_id}") ' \ - f'|> drop(columns: ["organisation", "organisation_id", "type", "project", "project_id"])' \ - f'|> aggregateWindow(every: 24h, fn: sum)' + query = ( + f'from(bucket:"{read_bucket}") ' + f"|> range(start: -30d, stop: now()) " + f'|> filter(fn:(r) => r._measurement == "api_call") ' + f'|> filter(fn: (r) => r["organisation_id"] == "{org_id}") ' + f'|> drop(columns: ["organisation", "organisation_id", "type", "project", "project_id"])' + f"|> aggregateWindow(every: 24h, fn: sum)" + ) mock_influxdb_client = mock.MagicMock() - monkeypatch.setattr(analytics.influxdb_wrapper, "influxdb_client", mock_influxdb_client) + monkeypatch.setattr( + analytics.influxdb_wrapper, "influxdb_client", mock_influxdb_client + ) mock_query_api = mock.MagicMock() mock_influxdb_client.query_api.return_value = mock_query_api @@ -70,15 +84,21 @@ def test_influx_db_query_when_get_events_list_then_query_api_called(monkeypatch) mock_query_api.query.assert_called_once_with(org=influx_org, query=query) -def test_influx_db_query_when_get_multiple_events_for_organistation_then_query_api_called(monkeypatch): - query = f'from(bucket:"{read_bucket}") ' \ - '|> range(start: -30d, stop: now()) ' \ - '|> filter(fn:(r) => r._measurement == "api_call") ' \ - f'|> filter(fn: (r) => r["organisation_id"] == "{org_id}") ' \ - '|> drop(columns: ["organisation", "organisation_id", "type", "project", "project_id"])' \ - '|> aggregateWindow(every: 24h, fn: sum)' +def test_influx_db_query_when_get_multiple_events_for_organistation_then_query_api_called( + monkeypatch, +): + query = ( + f'from(bucket:"{read_bucket}") ' + "|> range(start: -30d, stop: now()) " + '|> filter(fn:(r) => r._measurement == "api_call") ' + f'|> filter(fn: (r) => r["organisation_id"] == "{org_id}") ' + '|> drop(columns: ["organisation", "organisation_id", "type", "project", "project_id"])' + "|> aggregateWindow(every: 24h, fn: sum)" + ) mock_influxdb_client = mock.MagicMock() - monkeypatch.setattr(analytics.influxdb_wrapper, "influxdb_client", mock_influxdb_client) + monkeypatch.setattr( + analytics.influxdb_wrapper, "influxdb_client", mock_influxdb_client + ) mock_query_api = mock.MagicMock() mock_influxdb_client.query_api.return_value = mock_query_api @@ -88,4 +108,3 @@ def test_influx_db_query_when_get_multiple_events_for_organistation_then_query_a # Then mock_query_api.query.assert_called_once_with(org=influx_org, query=query) - diff --git a/src/analytics/tests/test_unit_track.py b/src/analytics/tests/test_unit_track.py index 2a5a00bd5604..16567d8e7b06 100644 --- a/src/analytics/tests/test_unit_track.py +++ b/src/analytics/tests/test_unit_track.py @@ -4,19 +4,27 @@ from django.conf import settings import analytics -from analytics.track import track_request_googleanalytics, track_request_influxdb +from analytics.track import ( + track_request_googleanalytics, + track_request_influxdb, +) -@pytest.mark.parametrize("request_uri, expected_ga_requests", ( +@pytest.mark.parametrize( + "request_uri, expected_ga_requests", + ( ("/api/v1/flags/", 2), ("/api/v1/identities/", 2), ("/api/v1/traits/", 2), ("/api/v1/features/", 1), - ("/health", 1) -)) + ("/health", 1), + ), +) @mock.patch("analytics.track.requests") @mock.patch("analytics.track.Environment") -def test_track_request_googleanalytics(MockEnvironment, mock_requests, request_uri, expected_ga_requests): +def test_track_request_googleanalytics( + MockEnvironment, mock_requests, request_uri, expected_ga_requests +): """ Verify that the correct number of calls are made to GA for the various uris. @@ -37,11 +45,14 @@ def test_track_request_googleanalytics(MockEnvironment, mock_requests, request_u assert mock_requests.post.call_count == expected_ga_requests -@pytest.mark.parametrize("request_uri, expected_resource", ( +@pytest.mark.parametrize( + "request_uri, expected_resource", + ( ("/api/v1/flags/", "flags"), ("/api/v1/identities/", "identities"), ("/api/v1/traits/", "traits"), -)) + ), +) @mock.patch("analytics.track.InfluxDBWrapper") @mock.patch("analytics.track.Environment") def test_track_request_sends_data_to_influxdb_for_tracked_uris( @@ -65,7 +76,10 @@ def test_track_request_sends_data_to_influxdb_for_tracked_uris( # Then call_list = MockInfluxDBWrapper.call_args_list assert len(call_list) == 1 - assert mock_influxdb.add_data_point.call_args_list[0][1]["tags"]["resource"] == expected_resource + assert ( + mock_influxdb.add_data_point.call_args_list[0][1]["tags"]["resource"] + == expected_resource + ) @mock.patch("analytics.track.InfluxDBWrapper") @@ -90,4 +104,3 @@ def test_track_request_does_not_send_data_to_influxdb_for_not_tracked_uris( # Then MockInfluxDBWrapper.assert_not_called() - diff --git a/src/analytics/track.py b/src/analytics/track.py index 0705cc3545ce..c8734e6c99c9 100644 --- a/src/analytics/track.py +++ b/src/analytics/track.py @@ -23,7 +23,7 @@ TRACKED_RESOURCE_ACTIONS = { "flags": "flags", "identities": "identity_flags", - "traits": "traits" + "traits": "traits", } @@ -31,6 +31,7 @@ def track_request_googleanalytics_async(request): return track_request_googleanalytics(request) + @postpone def track_request_influxdb_async(request): return track_request_influxdb(request) @@ -43,9 +44,9 @@ def get_resource_from_uri(request_uri): :param request: (HttpRequest) the request being made """ - split_uri = request_uri.split('/')[1:] - if not (len(split_uri) >= 3 and split_uri[0] == 'api'): - logger.debug('not tracking event for uri %s' % request_uri) + split_uri = request_uri.split("/")[1:] + if not (len(split_uri) >= 3 and split_uri[0] == "api"): + logger.debug("not tracking event for uri %s" % request_uri) # this isn't an API request so we don't need to track an event for it return None @@ -59,21 +60,30 @@ def track_request_googleanalytics(request): :param request: (HttpRequest) the request being made """ - pageview_data = DEFAULT_DATA + "t=pageview&dp=" + quote(request.path, safe='') + pageview_data = DEFAULT_DATA + "t=pageview&dp=" + quote(request.path, safe="") # send pageview request requests.post(GOOGLE_ANALYTICS_COLLECT_URL, data=pageview_data) resource = get_resource_from_uri(request.path) if resource in TRACKED_RESOURCE_ACTIONS: - environment = Environment.get_from_cache(request.headers.get('X-Environment-Key')) + environment = Environment.get_from_cache( + request.headers.get("X-Environment-Key") + ) track_event(environment.project.organisation.get_unique_slug(), resource) -def track_event(category, action, label='', value=''): - data = DEFAULT_DATA + "&t=event" + \ - "&ec=" + category + \ - "&ea=" + action + "&cid=" + str(uuid.uuid4()) +def track_event(category, action, label="", value=""): + data = ( + DEFAULT_DATA + + "&t=event" + + "&ec=" + + category + + "&ea=" + + action + + "&cid=" + + str(uuid.uuid4()) + ) data = data + "&el=" + label if label else data data = data + "&ev=" + value if value else data requests.post(GOOGLE_ANALYTICS_COLLECT_URL, data=data) @@ -88,14 +98,16 @@ def track_request_influxdb(request): resource = get_resource_from_uri(request.path) if resource and resource in TRACKED_RESOURCE_ACTIONS: - environment = Environment.get_from_cache(request.headers.get('X-Environment-Key')) + environment = Environment.get_from_cache( + request.headers.get("X-Environment-Key") + ) tags = { "resource": resource, "organisation": environment.project.organisation.get_unique_slug(), "organisation_id": environment.project.organisation_id, "project": environment.project.name, - "project_id": environment.project_id + "project_id": environment.project_id, } influxdb = InfluxDBWrapper("api_call") @@ -111,13 +123,9 @@ def track_feature_evaluation_influxdb(environment_id, feature_evaluations): :param feature_evaluations: (dict) A collection of key id / evaluation counts """ influxdb = InfluxDBWrapper("feature_evaluation") - + for feature_id, evaluation_count in feature_evaluations.items(): - tags = { - "feature_id": feature_id, - "environment_id": environment_id - } + tags = {"feature_id": feature_id, "environment_id": environment_id} influxdb.add_data_point("request_count", evaluation_count, tags=tags) - + influxdb.write() - \ No newline at end of file diff --git a/src/analytics/views.py b/src/analytics/views.py index d996cd3ab3f5..f68911b08d7b 100644 --- a/src/analytics/views.py +++ b/src/analytics/views.py @@ -10,16 +10,18 @@ from analytics.track import track_feature_evaluation_influxdb + class SDKAnalyticsFlags(GenericAPIView): """ Class to handle flag analytics events """ + permission_classes = (EnvironmentKeyPermissions,) authentication_classes = (EnvironmentKeyAuthentication,) def post(self, request, *args, **kwargs): """ - Send flag evaluation events from the SDK back to the API for reporting. + Send flag evaluation events from the SDK back to the API for reporting. """ track_feature_evaluation_influxdb(request.environment.id, request.data) return Response(status=status.HTTP_200_OK) diff --git a/src/api/apps.py b/src/api/apps.py index 5c27fd53a2d4..9a98cca1acad 100644 --- a/src/api/apps.py +++ b/src/api/apps.py @@ -4,4 +4,4 @@ class ApiConfig(AppConfig): - name = 'api' + name = "api" diff --git a/src/api/serializers.py b/src/api/serializers.py index 015789dc3722..8459d5cd58e8 100644 --- a/src/api/serializers.py +++ b/src/api/serializers.py @@ -2,4 +2,4 @@ class ErrorSerializer(serializers.Serializer): - message = serializers.CharField() \ No newline at end of file + message = serializers.CharField() diff --git a/src/api/urls/deprecated.py b/src/api/urls/deprecated.py index 211da3cf0a81..f95476e4c157 100644 --- a/src/api/urls/deprecated.py +++ b/src/api/urls/deprecated.py @@ -4,10 +4,13 @@ from environments.identities.views import SDKIdentitiesDeprecated from features.views import SDKFeatureStates -app_name = 'deprecated' +app_name = "deprecated" urlpatterns = [ - url(r'^identities/(?P[-\w@%.]+)/traits/(?P[-\w.]+)', SDKTraitsDeprecated.as_view()), - url(r'^identities/(?P[-\w@%.]+)/', SDKIdentitiesDeprecated.as_view()), - url(r'^flags/(?P[-\w@%.]+)', SDKFeatureStates.as_view()) -] \ No newline at end of file + url( + r"^identities/(?P[-\w@%.]+)/traits/(?P[-\w.]+)", + SDKTraitsDeprecated.as_view(), + ), + url(r"^identities/(?P[-\w@%.]+)/", SDKIdentitiesDeprecated.as_view()), + url(r"^flags/(?P[-\w@%.]+)", SDKFeatureStates.as_view()), +] diff --git a/src/api/urls/v1.py b/src/api/urls/v1.py index 3b96f4d92acb..6d612e2d391e 100644 --- a/src/api/urls/v1.py +++ b/src/api/urls/v1.py @@ -14,43 +14,47 @@ schema_view = get_schema_view( openapi.Info( title="Bullet Train API", - default_version='v1', + default_version="v1", description="", license=openapi.License(name="BSD License"), contact=openapi.Contact(email="support@bullet-train.io"), ), public=True, permission_classes=(permissions.AllowAny,), - authentication_classes=(authentication.SessionAuthentication,) + authentication_classes=(authentication.SessionAuthentication,), ) traits_router = routers.DefaultRouter() -traits_router.register(r'', SDKTraits, basename='sdk-traits') +traits_router.register(r"", SDKTraits, basename="sdk-traits") -app_name = 'v1' +app_name = "v1" urlpatterns = [ - url(r'^organisations/', include('organisations.urls'), name='organisations'), - url(r'^projects/', include('projects.urls'), name='projects'), - url(r'^environments/', include('environments.urls'), name='environments'), - url(r'^features/', include('features.urls'), name='features'), - url(r'^users/', include('users.urls')), - url(r'^e2etests/', include('e2etests.urls')), - url(r'^audit/', include('audit.urls')), - - url(r'^auth/', include('custom_auth.urls')), - + url(r"^organisations/", include("organisations.urls"), name="organisations"), + url(r"^projects/", include("projects.urls"), name="projects"), + url(r"^environments/", include("environments.urls"), name="environments"), + url(r"^features/", include("features.urls"), name="features"), + url(r"^users/", include("users.urls")), + url(r"^e2etests/", include("e2etests.urls")), + url(r"^audit/", include("audit.urls")), + url(r"^auth/", include("custom_auth.urls")), # Chargebee webhooks - url(r'cb-webhook/', chargebee_webhook, name='chargebee-webhook'), - + url(r"cb-webhook/", chargebee_webhook, name="chargebee-webhook"), # Client SDK urls - url(r'^flags/$', SDKFeatureStates.as_view(), name='flags'), - url(r'^identities/$', SDKIdentities.as_view(), name='sdk-identities'), - url(r'^traits/', include(traits_router.urls), name='traits'), - url(r'^segments/$', SDKSegments.as_view()), - url(r'^analytics/flags/$', SDKAnalyticsFlags.as_view()), - + url(r"^flags/$", SDKFeatureStates.as_view(), name="flags"), + url(r"^identities/$", SDKIdentities.as_view(), name="sdk-identities"), + url(r"^traits/", include(traits_router.urls), name="traits"), + url(r"^segments/$", SDKSegments.as_view()), + url(r"^analytics/flags/$", SDKAnalyticsFlags.as_view()), # API documentation - url(r'^swagger(?P\.json|\.yaml)$', schema_view.without_ui(cache_timeout=0), name='schema-json'), - url(r'^docs/$', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui') -] \ No newline at end of file + url( + r"^swagger(?P\.json|\.yaml)$", + schema_view.without_ui(cache_timeout=0), + name="schema-json", + ), + url( + r"^docs/$", + schema_view.with_ui("swagger", cache_timeout=0), + name="schema-swagger-ui", + ), +] diff --git a/src/app/handlers.py b/src/app/handlers.py index ab4c2d4bc185..4e8740662a20 100644 --- a/src/app/handlers.py +++ b/src/app/handlers.py @@ -1,6 +1,6 @@ +import errno import logging import os -import errno def mkdir_p(path): @@ -18,6 +18,6 @@ def mkdir_p(path): class MakeFileHandler(logging.FileHandler): - def __init__(self, filename, mode='a', encoding=None, delay=0): + def __init__(self, filename, mode="a", encoding=None, delay=0): mkdir_p(os.path.dirname(filename)) logging.FileHandler.__init__(self, filename, mode, encoding, delay) diff --git a/src/app/middleware.py b/src/app/middleware.py index 76368809bf2d..bade5787c848 100644 --- a/src/app/middleware.py +++ b/src/app/middleware.py @@ -12,12 +12,19 @@ def __init__(self, get_response): self.get_response = get_response def __call__(self, request): - if request.path.startswith('/admin'): - x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') - ip = x_forwarded_for.split(',')[0] if x_forwarded_for else request.META.get('REMOTE_ADDR') - if settings.ALLOWED_ADMIN_IP_ADDRESSES and ip not in settings.ALLOWED_ADMIN_IP_ADDRESSES: + if request.path.startswith("/admin"): + x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR") + ip = ( + x_forwarded_for.split(",")[0] + if x_forwarded_for + else request.META.get("REMOTE_ADDR") + ) + if ( + settings.ALLOWED_ADMIN_IP_ADDRESSES + and ip not in settings.ALLOWED_ADMIN_IP_ADDRESSES + ): # IP address not allowed! - logger.info('Denying access to admin for ip address %s' % ip) + logger.info("Denying access to admin for ip address %s" % ip) raise PermissionDenied() return self.get_response(request) @@ -25,7 +32,9 @@ def __call__(self, request): class AxesMiddleware(DefaultAxesMiddleware): def __call__(self, request): - if hasattr(request, "path") and any(url in request.path for url in settings.AXES_BLACKLISTED_URLS): + if hasattr(request, "path") and any( + url in request.path for url in settings.AXES_BLACKLISTED_URLS + ): return super().__call__(request) response = self.get_response(request) diff --git a/src/app/settings/common.py b/src/app/settings/common.py index 9bfd6768f1be..8f46737ef634 100644 --- a/src/app/settings/common.py +++ b/src/app/settings/common.py @@ -29,102 +29,103 @@ BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) PROJECT_ROOT = os.path.abspath(os.path.dirname(__file__)) -ENV = env('ENVIRONMENT', default='local') -if ENV not in ('local', 'dev', 'staging', 'production'): - warnings.warn('ENVIRONMENT env variable must be one of local, dev, staging or production') +ENV = env("ENVIRONMENT", default="local") +if ENV not in ("local", "dev", "staging", "production"): + warnings.warn( + "ENVIRONMENT env variable must be one of local, dev, staging or production" + ) -SECRET_KEY = env('DJANGO_SECRET_KEY', default=get_random_secret_key()) +SECRET_KEY = env("DJANGO_SECRET_KEY", default=get_random_secret_key()) -HOSTED_SEATS_LIMIT = int(os.environ.get('HOSTED_SEATS_LIMIT', 0)) +HOSTED_SEATS_LIMIT = int(os.environ.get("HOSTED_SEATS_LIMIT", 0)) # Google Analytics Configuration -GOOGLE_ANALYTICS_KEY = os.environ.get('GOOGLE_ANALYTICS_KEY', '') -GOOGLE_SERVICE_ACCOUNT = os.environ.get('GOOGLE_SERVICE_ACCOUNT') +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') + 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") + warnings.warn( + "GA_TABLE_ID not configured, getting organisation usage will not work" + ) -INFLUXDB_TOKEN = env.str('INFLUXDB_TOKEN', default='') -INFLUXDB_BUCKET = env.str('INFLUXDB_BUCKET', default='') -INFLUXDB_URL = env.str('INFLUXDB_URL', default='') -INFLUXDB_ORG = env.str('INFLUXDB_ORG', default='') +INFLUXDB_TOKEN = env.str("INFLUXDB_TOKEN", default="") +INFLUXDB_BUCKET = env.str("INFLUXDB_BUCKET", default="") +INFLUXDB_URL = env.str("INFLUXDB_URL", default="") +INFLUXDB_ORG = env.str("INFLUXDB_ORG", default="") -ALLOWED_HOSTS = env.list('DJANGO_ALLOWED_HOSTS', default=[]) -CSRF_TRUSTED_ORIGINS = env.list('DJANGO_CSRF_TRUSTED_ORIGINS', default=[]) +ALLOWED_HOSTS = env.list("DJANGO_ALLOWED_HOSTS", default=[]) +CSRF_TRUSTED_ORIGINS = env.list("DJANGO_CSRF_TRUSTED_ORIGINS", default=[]) -INTERNAL_IPS = ['127.0.0.1',] +INTERNAL_IPS = [ + "127.0.0.1", +] # In order to run a load balanced solution, we need to whitelist the internal ip try: - internal_ip = requests.get('http://instance-data/latest/meta-data/local-ipv4').text + internal_ip = requests.get("http://instance-data/latest/meta-data/local-ipv4").text except requests.exceptions.ConnectionError: pass else: ALLOWED_HOSTS.append(internal_ip) del requests -if sys.version[0] == '2': +if sys.version[0] == "2": reload(sys) sys.setdefaultencoding("utf-8") # Application definition INSTALLED_APPS = [ - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', - 'rest_framework', - 'rest_framework.authtoken', - 'djoser', - 'django.contrib.sites', - 'custom_auth', - 'admin_sso', - 'api', - 'corsheaders', - 'users', - 'organisations', - 'projects', - 'sales_dashboard', - - 'environments', - 'environments.permissions', - 'environments.identities', - 'environments.identities.traits', - - 'features', - 'segments', - 'e2etests', - 'simple_history', - 'drf_yasg2', - 'audit', - 'permissions', - 'projects.tags', - + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "rest_framework", + "rest_framework.authtoken", + "djoser", + "django.contrib.sites", + "custom_auth", + "admin_sso", + "api", + "corsheaders", + "users", + "organisations", + "projects", + "sales_dashboard", + "environments", + "environments.permissions", + "environments.identities", + "environments.identities.traits", + "features", + "segments", + "e2etests", + "simple_history", + "drf_yasg2", + "audit", + "permissions", + "projects.tags", # 2FA - 'trench', - + "trench", # health check plugins - 'health_check', - 'health_check.db', - + "health_check", + "health_check.db", # Used for ordering models (e.g. FeatureSegment) - 'ordered_model', - + "ordered_model", # Third party integrations - 'integrations.datadog', - 'integrations.amplitude', - + "integrations.datadog", + "integrations.amplitude", # Rate limiting admin endpoints - 'axes' + "axes", ] if GOOGLE_ANALYTICS_KEY or INFLUXDB_TOKEN: - INSTALLED_APPS.append('analytics') + INSTALLED_APPS.append("analytics") SITE_ID = 1 @@ -132,101 +133,100 @@ DATABASES = {} REST_FRAMEWORK = { - 'DEFAULT_PERMISSION_CLASSES': [ - 'rest_framework.permissions.IsAuthenticated' - ], - 'DEFAULT_AUTHENTICATION_CLASSES': ( - 'rest_framework.authentication.TokenAuthentication', + "DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticated"], + "DEFAULT_AUTHENTICATION_CLASSES": ( + "rest_framework.authentication.TokenAuthentication", ), - 'PAGE_SIZE': 10, - 'UNICODE_JSON': False, - 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', - 'DEFAULT_THROTTLE_RATES': { - 'login': '1/s' - } + "PAGE_SIZE": 10, + "UNICODE_JSON": False, + "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination", + "DEFAULT_THROTTLE_RATES": {"login": "1/s"}, } AUTHENTICATION_BACKENDS = [ - 'axes.backends.AxesBackend', - 'django.contrib.auth.backends.ModelBackend', + "axes.backends.AxesBackend", + "django.contrib.auth.backends.ModelBackend", ] MIDDLEWARE = [ - 'django.middleware.security.SecurityMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'corsheaders.middleware.CorsMiddleware', - 'django.middleware.common.CommonMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - 'django.middleware.clickjacking.XFrameOptionsMiddleware', - 'simple_history.middleware.HistoryRequestMiddleware', - 'app.middleware.AxesMiddleware', + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "corsheaders.middleware.CorsMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", + "simple_history.middleware.HistoryRequestMiddleware", + "app.middleware.AxesMiddleware", ] if GOOGLE_ANALYTICS_KEY: - MIDDLEWARE.append('analytics.middleware.GoogleAnalyticsMiddleware') + MIDDLEWARE.append("analytics.middleware.GoogleAnalyticsMiddleware") if INFLUXDB_TOKEN: - MIDDLEWARE.append('analytics.middleware.InfluxDBMiddleware') + MIDDLEWARE.append("analytics.middleware.InfluxDBMiddleware") -ALLOWED_ADMIN_IP_ADDRESSES = env.list('ALLOWED_ADMIN_IP_ADDRESSES', default=list()) +ALLOWED_ADMIN_IP_ADDRESSES = env.list("ALLOWED_ADMIN_IP_ADDRESSES", default=list()) if len(ALLOWED_ADMIN_IP_ADDRESSES) > 0: - warnings.warn('Restricting access to the admin site for ip addresses %s' % ', '.join(ALLOWED_ADMIN_IP_ADDRESSES)) - MIDDLEWARE.append('app.middleware.AdminWhitelistMiddleware') + warnings.warn( + "Restricting access to the admin site for ip addresses %s" + % ", ".join(ALLOWED_ADMIN_IP_ADDRESSES) + ) + MIDDLEWARE.append("app.middleware.AdminWhitelistMiddleware") -ROOT_URLCONF = 'app.urls' +ROOT_URLCONF = "app.urls" TEMPLATES = [ { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.debug', - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", ], }, }, ] -WSGI_APPLICATION = 'app.wsgi.application' +WSGI_APPLICATION = "app.wsgi.application" # Password validation # https://docs.djangoproject.com/en/1.9/ref/settings/#auth-password-validators AUTH_PASSWORD_VALIDATORS = [ { - 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", }, { - 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", }, { - 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", }, { - 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", }, ] AUTHENTICATION_BACKENDS = ( - 'admin_sso.auth.DjangoSSOAuthBackend', - 'django.contrib.auth.backends.ModelBackend', + "admin_sso.auth.DjangoSSOAuthBackend", + "django.contrib.auth.backends.ModelBackend", ) -DJANGO_ADMIN_SSO_OAUTH_CLIENT_ID = env.str('OAUTH_CLIENT_ID', default='') -DJANGO_ADMIN_SSO_OAUTH_CLIENT_SECRET = env.str('OAUTH_CLIENT_SECRET', default='') +DJANGO_ADMIN_SSO_OAUTH_CLIENT_ID = env.str("OAUTH_CLIENT_ID", default="") +DJANGO_ADMIN_SSO_OAUTH_CLIENT_SECRET = env.str("OAUTH_CLIENT_SECRET", default="") # Internationalization # https://docs.djangoproject.com/en/1.9/topics/i18n/ -LANGUAGE_CODE = 'en-us' +LANGUAGE_CODE = "en-us" -TIME_ZONE = 'UTC' +TIME_ZONE = "UTC" USE_I18N = True @@ -237,63 +237,56 @@ # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/1.9/howto/static-files/ -STATIC_URL = '/static/' -STATIC_ROOT = os.path.join(PROJECT_ROOT, '../../static/') +STATIC_URL = "/static/" +STATIC_ROOT = os.path.join(PROJECT_ROOT, "../../static/") # CORS settings CORS_ORIGIN_ALLOW_ALL = True -CORS_ALLOW_HEADERS = default_headers + ( - 'X-Environment-Key', - 'X-E2E-Test-Auth-Token' -) +CORS_ALLOW_HEADERS = default_headers + ("X-Environment-Key", "X-E2E-Test-Auth-Token") -DEFAULT_FROM_EMAIL = os.environ.get('SENDER_EMAIL', 'noreply@bullet-train.io') +DEFAULT_FROM_EMAIL = os.environ.get("SENDER_EMAIL", "noreply@bullet-train.io") EMAIL_CONFIGURATION = { # Invitations with name is anticipated to take two arguments. The persons name and the # organisation name they are invited to. - 'INVITE_SUBJECT_WITH_NAME': '%s has invited you to join the organisation \'%s\' on Bullet ' - 'Train', + "INVITE_SUBJECT_WITH_NAME": "%s has invited you to join the organisation '%s' on Bullet " + "Train", # Invitations without a name is anticipated to take one arguments. The organisation name they # are invited to. - 'INVITE_SUBJECT_WITHOUT_NAME': 'You have been invited to join the organisation \'%s\' on ' - 'Bullet Train', + "INVITE_SUBJECT_WITHOUT_NAME": "You have been invited to join the organisation '%s' on " + "Bullet Train", # The email address invitations will be sent from. - 'INVITE_FROM_EMAIL': DEFAULT_FROM_EMAIL, - + "INVITE_FROM_EMAIL": DEFAULT_FROM_EMAIL, } -AWS_SES_REGION_NAME = os.environ.get('AWS_SES_REGION_NAME') -AWS_SES_REGION_ENDPOINT = os.environ.get('AWS_SES_REGION_ENDPOINT') +AWS_SES_REGION_NAME = os.environ.get("AWS_SES_REGION_NAME") +AWS_SES_REGION_ENDPOINT = os.environ.get("AWS_SES_REGION_ENDPOINT") # Used on init to create admin user for the site, update accordingly before hitting /auth/init ALLOW_ADMIN_INITIATION_VIA_URL = True ADMIN_EMAIL = "admin@example.com" ADMIN_INITIAL_PASSWORD = "password" -AUTH_USER_MODEL = 'users.FFAdminUser' +AUTH_USER_MODEL = "users.FFAdminUser" ACCOUNT_USERNAME_REQUIRED = False ACCOUNT_EMAIL_REQUIRED = True -ACCOUNT_AUTHENTICATION_METHOD = 'email' -ACCOUNT_EMAIL_VERIFICATION = 'none' # TODO: configure email verification +ACCOUNT_AUTHENTICATION_METHOD = "email" +ACCOUNT_EMAIL_VERIFICATION = "none" # TODO: configure email verification # SendGrid -EMAIL_BACKEND = os.environ.get('EMAIL_BACKEND', 'sgbackend.SendGridBackend') -SENDGRID_API_KEY = os.environ.get('SENDGRID_API_KEY') -if EMAIL_BACKEND == 'sgbackend.SendGridBackend' and not SENDGRID_API_KEY: +EMAIL_BACKEND = os.environ.get("EMAIL_BACKEND", "sgbackend.SendGridBackend") +SENDGRID_API_KEY = os.environ.get("SENDGRID_API_KEY") +if EMAIL_BACKEND == "sgbackend.SendGridBackend" and not SENDGRID_API_KEY: warnings.warn( - "`SENDGRID_API_KEY` has not been configured. You will not receive emails.") + "`SENDGRID_API_KEY` has not been configured. You will not receive emails." + ) SWAGGER_SETTINGS = { - 'SHOW_REQUEST_HEADERS': True, - 'SECURITY_DEFINITIONS': { - 'api_key': { - 'type': 'apiKey', - 'in': 'header', - 'name': 'Authorization' - } - } + "SHOW_REQUEST_HEADERS": True, + "SECURITY_DEFINITIONS": { + "api_key": {"type": "apiKey", "in": "header", "name": "Authorization"} + }, } LOGIN_URL = "/admin/login/" @@ -302,124 +295,121 @@ # Email associated with user that is used by front end for end to end testing purposes FE_E2E_TEST_USER_EMAIL = "nightwatch@solidstategroup.com" -SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') +SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") # Chargebee -ENABLE_CHARGEBEE = os.environ.get('ENABLE_CHARGEBEE', False) -CHARGEBEE_API_KEY = os.environ.get('CHARGEBEE_API_KEY') -CHARGEBEE_SITE = os.environ.get('CHARGEBEE_SITE') +ENABLE_CHARGEBEE = os.environ.get("ENABLE_CHARGEBEE", False) +CHARGEBEE_API_KEY = os.environ.get("CHARGEBEE_API_KEY") +CHARGEBEE_SITE = os.environ.get("CHARGEBEE_SITE") LOGGING = { - 'version': 1, - 'disable_existing_loggers': False, - 'formatters': { - 'console_format': { - 'format': '%(name)-12s %(levelname)-8s %(message)s' - } + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "console_format": {"format": "%(name)-12s %(levelname)-8s %(message)s"} }, - 'handlers': { - 'console': { - 'level': 'DEBUG', - 'class': 'logging.StreamHandler', - 'formatter': 'console_format', + "handlers": { + "console": { + "level": "DEBUG", + "class": "logging.StreamHandler", + "formatter": "console_format", }, }, - 'loggers': { - 'django': { - 'level': 'INFO', - 'handlers': ['console'] - }, - '': { - 'level': 'DEBUG', - 'handlers': ['console'], + "loggers": { + "django": {"level": "INFO", "handlers": ["console"]}, + "": { + "level": "DEBUG", + "handlers": ["console"], }, - } + }, } -CACHE_FLAGS_SECONDS = int(os.environ.get('CACHE_FLAGS_SECONDS', 0)) -FLAGS_CACHE_LOCATION = 'environment-flags' -ENVIRONMENT_CACHE_LOCATION = 'environment-objects' +CACHE_FLAGS_SECONDS = int(os.environ.get("CACHE_FLAGS_SECONDS", 0)) +FLAGS_CACHE_LOCATION = "environment-flags" +ENVIRONMENT_CACHE_LOCATION = "environment-objects" -CACHE_PROJECT_SEGMENTS_SECONDS = env.int('CACHE_PROJECT_SEGMENTS_SECONDS', 0) -PROJECT_SEGMENTS_CACHE_LOCATION = 'project-segments' +CACHE_PROJECT_SEGMENTS_SECONDS = env.int("CACHE_PROJECT_SEGMENTS_SECONDS", 0) +PROJECT_SEGMENTS_CACHE_LOCATION = "project-segments" CACHES = { - 'default': { - 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', - 'LOCATION': 'unique-snowflake', + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + "LOCATION": "unique-snowflake", }, ENVIRONMENT_CACHE_LOCATION: { - 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', - 'LOCATION': ENVIRONMENT_CACHE_LOCATION + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + "LOCATION": ENVIRONMENT_CACHE_LOCATION, }, FLAGS_CACHE_LOCATION: { - 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', - 'LOCATION': FLAGS_CACHE_LOCATION, + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + "LOCATION": FLAGS_CACHE_LOCATION, }, PROJECT_SEGMENTS_CACHE_LOCATION: { - 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', - 'LOCATION': PROJECT_SEGMENTS_CACHE_LOCATION, - } + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + "LOCATION": PROJECT_SEGMENTS_CACHE_LOCATION, + }, } -LOG_LEVEL = env.str('LOG_LEVEL', 'WARNING') +LOG_LEVEL = env.str("LOG_LEVEL", "WARNING") TRENCH_AUTH = { - 'FROM_EMAIL': DEFAULT_FROM_EMAIL, - 'BACKUP_CODES_QUANTITY': 5, - 'BACKUP_CODES_LENGTH': 10, # keep (quantity * length) under 200 - 'BACKUP_CODES_CHARACTERS': ( - 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' + "FROM_EMAIL": DEFAULT_FROM_EMAIL, + "BACKUP_CODES_QUANTITY": 5, + "BACKUP_CODES_LENGTH": 10, # keep (quantity * length) under 200 + "BACKUP_CODES_CHARACTERS": ( + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" ), - 'DEFAULT_VALIDITY_PERIOD': 30, - 'CONFIRM_BACKUP_CODES_REGENERATION_WITH_CODE': True, - 'APPLICATION_ISSUER_NAME': 'app.bullet-train.io', - 'MFA_METHODS': { - 'app': { - 'VERBOSE_NAME': 'TOTP App', - 'VALIDITY_PERIOD': 60 * 10, - 'USES_THIRD_PARTY_CLIENT': True, - 'HANDLER': 'custom_auth.mfa.backends.application.CustomApplicationBackend', + "DEFAULT_VALIDITY_PERIOD": 30, + "CONFIRM_BACKUP_CODES_REGENERATION_WITH_CODE": True, + "APPLICATION_ISSUER_NAME": "app.bullet-train.io", + "MFA_METHODS": { + "app": { + "VERBOSE_NAME": "TOTP App", + "VALIDITY_PERIOD": 60 * 10, + "USES_THIRD_PARTY_CLIENT": True, + "HANDLER": "custom_auth.mfa.backends.application.CustomApplicationBackend", }, }, } -USER_CREATE_PERMISSIONS = env.list('USER_CREATE_PERMISSIONS', default=['rest_framework.permissions.AllowAny']) +USER_CREATE_PERMISSIONS = env.list( + "USER_CREATE_PERMISSIONS", default=["rest_framework.permissions.AllowAny"] +) DJOSER = { - 'PASSWORD_RESET_CONFIRM_URL': 'password-reset/confirm/{uid}/{token}', + "PASSWORD_RESET_CONFIRM_URL": "password-reset/confirm/{uid}/{token}", # if True user required to click activation link in email to activate account - 'SEND_ACTIVATION_EMAIL': env.bool('ENABLE_EMAIL_ACTIVATION', default=False), - 'ACTIVATION_URL': 'activate/{uid}/{token}', # FE uri to redirect user to from activation email - 'SEND_CONFIRMATION_EMAIL': False, # register or activation endpoint will send confirmation email to user - 'SERIALIZERS': { - 'token': 'custom_auth.serializers.CustomTokenSerializer', - 'user_create': 'custom_auth.serializers.CustomUserCreateSerializer', - 'current_user': 'users.serializers.CustomCurrentUserSerializer', + "SEND_ACTIVATION_EMAIL": env.bool("ENABLE_EMAIL_ACTIVATION", default=False), + "ACTIVATION_URL": "activate/{uid}/{token}", # FE uri to redirect user to from activation email + "SEND_CONFIRMATION_EMAIL": False, # register or activation endpoint will send confirmation email to user + "SERIALIZERS": { + "token": "custom_auth.serializers.CustomTokenSerializer", + "user_create": "custom_auth.serializers.CustomUserCreateSerializer", + "current_user": "users.serializers.CustomCurrentUserSerializer", + }, + "EMAIL": { + "activation": "users.emails.ActivationEmail", + "confirmation": "users.emails.ConfirmationEmail", }, - 'EMAIL': { - 'activation': 'users.emails.ActivationEmail', - 'confirmation': 'users.emails.ConfirmationEmail' + "SET_PASSWORD_RETYPE": True, + "PASSWORD_RESET_CONFIRM_RETYPE": True, + "HIDE_USERS": True, + "PERMISSIONS": { + "user": ["custom_auth.permissions.CurrentUser"], + "user_list": ["custom_auth.permissions.CurrentUser"], + "user_create": USER_CREATE_PERMISSIONS, }, - 'SET_PASSWORD_RETYPE': True, - 'PASSWORD_RESET_CONFIRM_RETYPE': True, - 'HIDE_USERS': True, - 'PERMISSIONS': { - 'user': ['custom_auth.permissions.CurrentUser'], - 'user_list': ['custom_auth.permissions.CurrentUser'], - 'user_create': USER_CREATE_PERMISSIONS, - } } # Github OAuth credentials -GITHUB_CLIENT_ID = env.str('GITHUB_CLIENT_ID', '') -GITHUB_CLIENT_SECRET = env.str('GITHUB_CLIENT_SECRET', '') +GITHUB_CLIENT_ID = env.str("GITHUB_CLIENT_ID", "") +GITHUB_CLIENT_SECRET = env.str("GITHUB_CLIENT_SECRET", "") # Django Axes settings -AXES_COOLOFF_TIME = timedelta(minutes=env.int('AXES_COOLOFF_TIME', 15)) +AXES_COOLOFF_TIME = timedelta(minutes=env.int("AXES_COOLOFF_TIME", 15)) AXES_BLACKLISTED_URLS = [ - '/admin/login/?next=/admin', - '/admin/', + "/admin/login/?next=/admin", + "/admin/", ] diff --git a/src/app/settings/develop.py b/src/app/settings/develop.py index e1c8d6a17769..6d441e8c02e3 100644 --- a/src/app/settings/develop.py +++ b/src/app/settings/develop.py @@ -1,38 +1,27 @@ -from app.settings.common import * import os import dj_database_url -DATABASES['default'] = dj_database_url.parse(os.environ['DATABASE_URL']) +from app.settings.common import * + +DATABASES["default"] = dj_database_url.parse(os.environ["DATABASE_URL"]) DEBUG = True LOGGING = { - 'version': 1, - 'disable_existing_loggers': False, - 'formatters': { - 'verbose': { - 'format': '%(levelname)s %(asctime)s %(module)s %(process)d %(thread)d %(message)s' - }, - 'simple': { - 'format': '%(levelname)s %(message)s' + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "verbose": { + "format": "%(levelname)s %(asctime)s %(module)s %(process)d %(thread)d %(message)s" }, + "simple": {"format": "%(levelname)s %(message)s"}, }, - 'handlers': { - 'console': { - 'class': 'logging.StreamHandler', - 'formatter': 'verbose' - }, + "handlers": { + "console": {"class": "logging.StreamHandler", "formatter": "verbose"}, + }, + "loggers": { + "django": {"handlers": ["console"], "propagate": True, "level": "INFO"}, + "gunicorn": {"handlers": ["console"], "level": "DEBUG"}, }, - 'loggers': { - 'django': { - 'handlers': ['console'], - 'propagate': True, - 'level': 'INFO' - }, - 'gunicorn': { - 'handlers': ['console'], - 'level': 'DEBUG' - } - } } diff --git a/src/app/settings/local.py b/src/app/settings/local.py index da008dd0b985..645da02174d3 100644 --- a/src/app/settings/local.py +++ b/src/app/settings/local.py @@ -1,24 +1,24 @@ -from app.settings.common import * import os +from app.settings.common import * -ALLOWED_HOSTS.extend(['.ngrok.io', '127.0.0.1', 'localhost']) +ALLOWED_HOSTS.extend([".ngrok.io", "127.0.0.1", "localhost"]) -INSTALLED_APPS.extend(['debug_toolbar']) +INSTALLED_APPS.extend(["debug_toolbar"]) -MIDDLEWARE.extend(['debug_toolbar.middleware.DebugToolbarMiddleware']) +MIDDLEWARE.extend(["debug_toolbar.middleware.DebugToolbarMiddleware"]) DEBUG = True DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.postgresql', - 'NAME': os.getenv('POSTGRES_DATABASE', 'bullettrain'), - 'USER': os.getenv('POSTGRES_USER', 'postgres'), - 'PASSWORD': os.environ['POSTGRES_PASSWORD'], - 'HOST': os.getenv('POSTGRES_HOST', '127.0.0.1'), - 'PORT': os.getenv('POSTGRES_PORT', 5432) + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": os.getenv("POSTGRES_DATABASE", "bullettrain"), + "USER": os.getenv("POSTGRES_USER", "postgres"), + "PASSWORD": os.environ["POSTGRES_PASSWORD"], + "HOST": os.getenv("POSTGRES_HOST", "127.0.0.1"), + "PORT": os.getenv("POSTGRES_PORT", 5432), } } -EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' \ No newline at end of file +EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" diff --git a/src/app/settings/master-docker.py b/src/app/settings/master-docker.py index 3a310e8b1c4c..55d7d3ad4ff8 100644 --- a/src/app/settings/master-docker.py +++ b/src/app/settings/master-docker.py @@ -1,49 +1,36 @@ from app.settings.common import * - -ALLOWED_HOSTS.extend(['.ngrok.io', '127.0.0.1', 'localhost']) +ALLOWED_HOSTS.extend([".ngrok.io", "127.0.0.1", "localhost"]) DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.postgresql', - 'NAME': os.environ['DJANGO_DB_NAME'], - 'USER': os.environ['DJANGO_DB_USER'], - 'PASSWORD': os.environ['DJANGO_DB_PASSWORD'], - 'HOST': os.environ['DJANGO_DB_HOST'], - 'PORT': os.environ['DJANGO_DB_PORT'], + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": os.environ["DJANGO_DB_NAME"], + "USER": os.environ["DJANGO_DB_USER"], + "PASSWORD": os.environ["DJANGO_DB_PASSWORD"], + "HOST": os.environ["DJANGO_DB_HOST"], + "PORT": os.environ["DJANGO_DB_PORT"], } } DEBUG = False LOGGING = { - 'version': 1, - 'disable_existing_loggers': False, - 'formatters': { - 'verbose': { - 'format': '%(levelname)s %(asctime)s %(module)s %(process)d %(thread)d %(message)s' - }, - 'simple': { - 'format': '%(levelname)s %(message)s' + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "verbose": { + "format": "%(levelname)s %(asctime)s %(module)s %(process)d %(thread)d %(message)s" }, + "simple": {"format": "%(levelname)s %(message)s"}, }, - 'handlers': { - 'console': { - 'class': 'logging.StreamHandler', - 'formatter': 'verbose' - }, + "handlers": { + "console": {"class": "logging.StreamHandler", "formatter": "verbose"}, + }, + "loggers": { + "django": {"handlers": ["console"], "propagate": True, "level": "INFO"}, + "gunicorn": {"handlers": ["console"], "level": "DEBUG"}, }, - 'loggers': { - 'django': { - 'handlers': ['console'], - 'propagate': True, - 'level': 'INFO' - }, - 'gunicorn': { - 'handlers': ['console'], - 'level': 'DEBUG' - } - } } -REST_FRAMEWORK['PAGE_SIZE'] = 999 +REST_FRAMEWORK["PAGE_SIZE"] = 999 diff --git a/src/app/settings/master.py b/src/app/settings/master.py index 468723acb918..79272f3c17d3 100644 --- a/src/app/settings/master.py +++ b/src/app/settings/master.py @@ -1,40 +1,31 @@ -from app.settings.common import * import os import dj_database_url -DATABASES['default'] = dj_database_url.parse(os.environ['DATABASE_URL'], conn_max_age=60) +from app.settings.common import * + +DATABASES["default"] = dj_database_url.parse( + os.environ["DATABASE_URL"], conn_max_age=60 +) DEBUG = False LOGGING = { - 'version': 1, - 'disable_existing_loggers': False, - 'formatters': { - 'verbose': { - 'format': '%(levelname)s %(asctime)s %(module)s %(process)d %(thread)d %(message)s' - }, - 'simple': { - 'format': '%(levelname)s %(message)s' + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "verbose": { + "format": "%(levelname)s %(asctime)s %(module)s %(process)d %(thread)d %(message)s" }, + "simple": {"format": "%(levelname)s %(message)s"}, }, - 'handlers': { - 'console': { - 'class': 'logging.StreamHandler', - 'formatter': 'verbose' - }, + "handlers": { + "console": {"class": "logging.StreamHandler", "formatter": "verbose"}, + }, + "loggers": { + "django": {"handlers": ["console"], "propagate": True, "level": "INFO"}, + "gunicorn": {"handlers": ["console"], "level": "DEBUG"}, }, - 'loggers': { - 'django': { - 'handlers': ['console'], - 'propagate': True, - 'level': 'INFO' - }, - 'gunicorn': { - 'handlers': ['console'], - 'level': 'DEBUG' - } - } } -REST_FRAMEWORK['PAGE_SIZE'] = 999 +REST_FRAMEWORK["PAGE_SIZE"] = 999 diff --git a/src/app/settings/staging.py b/src/app/settings/staging.py index 468723acb918..79272f3c17d3 100644 --- a/src/app/settings/staging.py +++ b/src/app/settings/staging.py @@ -1,40 +1,31 @@ -from app.settings.common import * import os import dj_database_url -DATABASES['default'] = dj_database_url.parse(os.environ['DATABASE_URL'], conn_max_age=60) +from app.settings.common import * + +DATABASES["default"] = dj_database_url.parse( + os.environ["DATABASE_URL"], conn_max_age=60 +) DEBUG = False LOGGING = { - 'version': 1, - 'disable_existing_loggers': False, - 'formatters': { - 'verbose': { - 'format': '%(levelname)s %(asctime)s %(module)s %(process)d %(thread)d %(message)s' - }, - 'simple': { - 'format': '%(levelname)s %(message)s' + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "verbose": { + "format": "%(levelname)s %(asctime)s %(module)s %(process)d %(thread)d %(message)s" }, + "simple": {"format": "%(levelname)s %(message)s"}, }, - 'handlers': { - 'console': { - 'class': 'logging.StreamHandler', - 'formatter': 'verbose' - }, + "handlers": { + "console": {"class": "logging.StreamHandler", "formatter": "verbose"}, + }, + "loggers": { + "django": {"handlers": ["console"], "propagate": True, "level": "INFO"}, + "gunicorn": {"handlers": ["console"], "level": "DEBUG"}, }, - 'loggers': { - 'django': { - 'handlers': ['console'], - 'propagate': True, - 'level': 'INFO' - }, - 'gunicorn': { - 'handlers': ['console'], - 'level': 'DEBUG' - } - } } -REST_FRAMEWORK['PAGE_SIZE'] = 999 +REST_FRAMEWORK["PAGE_SIZE"] = 999 diff --git a/src/app/settings/test.py b/src/app/settings/test.py index a0ececb12bdb..cdbb9d4a98fb 100644 --- a/src/app/settings/test.py +++ b/src/app/settings/test.py @@ -1,6 +1,7 @@ -from app.settings.common import * import os import dj_database_url -DATABASES['default'] = dj_database_url.parse(os.environ['DATABASE_URL']) \ No newline at end of file +from app.settings.common import * + +DATABASES["default"] = dj_database_url.parse(os.environ["DATABASE_URL"]) diff --git a/src/app/tests/test_middleware.py b/src/app/tests/test_middleware.py index 30d1a183b339..b49073aae211 100644 --- a/src/app/tests/test_middleware.py +++ b/src/app/tests/test_middleware.py @@ -6,17 +6,19 @@ from app.middleware import AdminWhitelistMiddleware -@mock.patch('app.middleware.settings') -def test_admin_whitelist_middleware_raises_permission_denied_for_admin_pages_if_ip_not_allowed(mock_settings): +@mock.patch("app.middleware.settings") +def test_admin_whitelist_middleware_raises_permission_denied_for_admin_pages_if_ip_not_allowed( + mock_settings, +): # Given - allowed_ip_address = '10.0.0.1' - not_allowed_ip_address = '11.0.0.1' + allowed_ip_address = "10.0.0.1" + not_allowed_ip_address = "11.0.0.1" mock_get_response = mock.MagicMock() mock_request = mock.MagicMock() - mock_request.path = '/admin/login' - mock_request.META = {'REMOTE_ADDR': not_allowed_ip_address} + mock_request.path = "/admin/login" + mock_request.META = {"REMOTE_ADDR": not_allowed_ip_address} mock_settings.ALLOWED_ADMIN_IP_ADDRESSES = [allowed_ip_address] @@ -29,18 +31,20 @@ def test_admin_whitelist_middleware_raises_permission_denied_for_admin_pages_if_ # Then - exception raised -@mock.patch('app.middleware.settings') -def test_admin_whitelist_middleware_returns_get_response_for_admin_pages_if_ip_allowed(mock_settings): +@mock.patch("app.middleware.settings") +def test_admin_whitelist_middleware_returns_get_response_for_admin_pages_if_ip_allowed( + mock_settings, +): # Given - allowed_ip_address = '10.0.0.1' + allowed_ip_address = "10.0.0.1" mock_get_response = mock.MagicMock() mock_get_response_return = mock.MagicMock() mock_get_response.return_value = mock_get_response_return mock_request = mock.MagicMock() - mock_request.path = '/admin/login' - mock_request.META = {'REMOTE_ADDR': allowed_ip_address} + mock_request.path = "/admin/login" + mock_request.META = {"REMOTE_ADDR": allowed_ip_address} mock_settings.ALLOWED_ADMIN_IP_ADDRESSES = [allowed_ip_address] @@ -54,19 +58,21 @@ def test_admin_whitelist_middleware_returns_get_response_for_admin_pages_if_ip_a assert response == mock_get_response_return -@mock.patch('app.middleware.settings') -def test_admin_whitelist_middleware_returns_get_response_for_non_admin_request_if_ip_not_allowed(mock_settings): +@mock.patch("app.middleware.settings") +def test_admin_whitelist_middleware_returns_get_response_for_non_admin_request_if_ip_not_allowed( + mock_settings, +): # Given - allowed_ip_address = '10.0.0.1' - not_allowed_ip_address = '11.0.0.1' + allowed_ip_address = "10.0.0.1" + not_allowed_ip_address = "11.0.0.1" mock_get_response = mock.MagicMock() mock_get_response_return = mock.MagicMock() mock_get_response.return_value = mock_get_response_return mock_request = mock.MagicMock() - mock_request.path = '/api/v1/flags' - mock_request.META = {'REMOTE_ADDR': not_allowed_ip_address} + mock_request.path = "/api/v1/flags" + mock_request.META = {"REMOTE_ADDR": not_allowed_ip_address} mock_settings.ALLOWED_ADMIN_IP_ADDRESSES = [allowed_ip_address] diff --git a/src/app/tests/test_urls.py b/src/app/tests/test_urls.py index 7345e8d983e3..e6b1e6a834ab 100644 --- a/src/app/tests/test_urls.py +++ b/src/app/tests/test_urls.py @@ -6,8 +6,8 @@ class HealthChecksTestCase(APITestCase): def test_health_check_endpoint_returns_200(self): # Given - base_url = reverse('health:health_check_home') - url = base_url + '?format=json' + base_url = reverse("health:health_check_home") + url = base_url + "?format=json" # When res = self.client.get(url) diff --git a/src/app/urls.py b/src/app/urls.py index de4bcba635ee..670aed611130 100644 --- a/src/app/urls.py +++ b/src/app/urls.py @@ -6,23 +6,24 @@ from users.views import password_reset_redirect urlpatterns = [ - url(r'^api/v1/', include('api.urls.deprecated', namespace='api-deprecated')), - url(r'^api/v1/', include('api.urls.v1', namespace='api-v1')), - url(r'^admin/', admin.site.urls), - url(r'^health', include('health_check.urls', namespace='health')), - url(r'^sales-dashboard/', include('sales_dashboard.urls')), - - url(r'', lambda r: HttpResponse("Bullet Train API")), - + url(r"^api/v1/", include("api.urls.deprecated", namespace="api-deprecated")), + url(r"^api/v1/", include("api.urls.v1", namespace="api-v1")), + url(r"^admin/", admin.site.urls), + url(r"^health", include("health_check.urls", namespace="health")), + url(r"^sales-dashboard/", include("sales_dashboard.urls")), + url(r"", lambda r: HttpResponse("Bullet Train API")), # this url is used to generate email content for the password reset workflow - url(r'^password-reset/confirm/(?P[0-9A-Za-z_\-]+)/(?P[0-9A-Za-z]{1,' - r'13}-[0-9A-Za-z]{1,20})/$', + url( + r"^password-reset/confirm/(?P[0-9A-Za-z_\-]+)/(?P[0-9A-Za-z]{1," + r"13}-[0-9A-Za-z]{1,20})/$", password_reset_redirect, - name='password_reset_confirm'), + name="password_reset_confirm", + ), ] if settings.DEBUG: import debug_toolbar + urlpatterns = [ - url(r'^__debug__/', include(debug_toolbar.urls)), + url(r"^__debug__/", include(debug_toolbar.urls)), ] + urlpatterns diff --git a/src/audit/__init__.py b/src/audit/__init__.py index 3a3734a97738..9a6d49f35270 100644 --- a/src/audit/__init__.py +++ b/src/audit/__init__.py @@ -1 +1 @@ -default_app_config = 'audit.apps.AuditConfig' \ No newline at end of file +default_app_config = "audit.apps.AuditConfig" diff --git a/src/audit/apps.py b/src/audit/apps.py index 7add11d1f7e9..0eb21352b9e0 100644 --- a/src/audit/apps.py +++ b/src/audit/apps.py @@ -4,7 +4,7 @@ class AuditConfig(AppConfig): - name = 'audit' + name = "audit" def ready(self): from . import signals diff --git a/src/audit/models.py b/src/audit/models.py index b18aa086ac5e..23029f3d52cd 100644 --- a/src/audit/models.py +++ b/src/audit/models.py @@ -9,12 +9,20 @@ 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 in environment: %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" -IDENTITY_FEATURE_STATE_UPDATED_MESSAGE = "Flag state / Remote config value updated for feature '%s' and identity '%s'" -IDENTITY_FEATURE_STATE_DELETED_MESSAGE = "Flag state / Remote config value deleted for feature '%s' and identity '%s'" +FEATURE_STATE_UPDATED_MESSAGE = ( + "Flag state / Remote Config value updated for feature: %s" +) +IDENTITY_FEATURE_STATE_UPDATED_MESSAGE = ( + "Flag state / Remote config value updated for feature '%s' and identity '%s'" +) +IDENTITY_FEATURE_STATE_DELETED_MESSAGE = ( + "Flag state / Remote config value deleted for feature '%s' and identity '%s'" +) class RelatedObjectType(enum.Enum): @@ -29,31 +37,43 @@ class RelatedObjectType(enum.Enum): @python_2_unicode_compatible class AuditLog(models.Model): - created_date = models.DateTimeField('DateCreated', auto_now_add=True) - project = models.ForeignKey(Project, related_name='audit_logs', null=True, on_delete=models.SET_NULL) + created_date = models.DateTimeField("DateCreated", auto_now_add=True) + project = models.ForeignKey( + Project, related_name="audit_logs", null=True, on_delete=models.SET_NULL + ) environment = models.ForeignKey( - 'environments.Environment', related_name='audit_logs', null=True, on_delete=models.SET_NULL) + "environments.Environment", + related_name="audit_logs", + null=True, + on_delete=models.SET_NULL, + ) log = models.TextField() author = models.ForeignKey( - 'users.FFAdminUser', related_name='audit_logs', null=True, blank=True, on_delete=models.SET_NULL) + "users.FFAdminUser", + related_name="audit_logs", + null=True, + blank=True, + on_delete=models.SET_NULL, + ) related_object_id = models.IntegerField(null=True) related_object_type = models.CharField(max_length=20, null=True) class Meta: verbose_name_plural = "Audit Logs" - ordering = ('-created_date',) + ordering = ("-created_date",) def __str__(self): return "Audit Log %s" % self.id @classmethod - def create_record(cls, obj, obj_type, log_message, author, project=None, environment=None): + 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 + environment=environment, ) - diff --git a/src/audit/serializers.py b/src/audit/serializers.py index 2ab4de61b066..d0bd23d8ae07 100644 --- a/src/audit/serializers.py +++ b/src/audit/serializers.py @@ -13,4 +13,12 @@ class AuditLogSerializer(serializers.ModelSerializer): class Meta: model = AuditLog - fields = ('created_date', 'log', 'author', 'environment', 'project', 'related_object_id', 'related_object_type') + fields = ( + "created_date", + "log", + "author", + "environment", + "project", + "related_object_id", + "related_object_type", + ) diff --git a/src/audit/signals.py b/src/audit/signals.py index 9a2665a36b9d..789f02601dba 100644 --- a/src/audit/signals.py +++ b/src/audit/signals.py @@ -1,10 +1,11 @@ -from audit.models import AuditLog, RelatedObjectType -from audit.serializers import AuditLogSerializer from django.db.models.signals import post_save from django.dispatch import receiver + +from audit.models import AuditLog, RelatedObjectType +from audit.serializers import AuditLogSerializer from integrations.datadog.datadog import DataDogWrapper from util.logging import get_logger -from webhooks.webhooks import call_organisation_webhooks, WebhookEventType +from webhooks.webhooks import WebhookEventType, call_organisation_webhooks logger = get_logger(__name__) @@ -14,10 +15,14 @@ def call_webhooks(sender, instance, **kwargs): data = AuditLogSerializer(instance=instance).data if not (instance.project or instance.environment): - logger.warning('Audit log without project or environment. Not sending webhook.') + logger.warning("Audit log without project or environment. Not sending webhook.") return - organisation = instance.project.organisation if instance.project else instance.environment.project.organisation + organisation = ( + instance.project.organisation + if instance.project + else instance.environment.project.organisation + ) call_organisation_webhooks(organisation, data, WebhookEventType.AUDIT_LOG_CREATED) @@ -27,21 +32,34 @@ def send_audit_log_event_to_datadog(sender, instance, **kwargs): logger.warning("Audit log missing project, not sending data to DataDog.") return - if not hasattr(instance.project, 'data_dog_config'): - logger.debug("No datadog integration configured for project %s" % instance.project.id) + if not hasattr(instance.project, "data_dog_config"): + logger.debug( + "No datadog integration configured for project %s" % instance.project.id + ) return # Only handle Feature related changes - if instance.related_object_type not in [RelatedObjectType.FEATURE.name, RelatedObjectType.FEATURE_STATE.name, - RelatedObjectType.SEGMENT.name]: - logger.debug("Ignoring none Flag audit event %s for datadog" % instance.related_object_type) + if instance.related_object_type not in [ + RelatedObjectType.FEATURE.name, + RelatedObjectType.FEATURE_STATE.name, + RelatedObjectType.SEGMENT.name, + ]: + logger.debug( + "Ignoring none Flag audit event %s for datadog" + % instance.related_object_type + ) return data_dog_config = instance.project.data_dog_config - data_dog = DataDogWrapper(base_url=data_dog_config.base_url, api_key=data_dog_config.api_key) - event_data = data_dog.generate_event_data(log=instance.log, - email=instance.author.email if instance.author else '', - environment_name=instance.environment.name.lower() - if instance.environment else '') + data_dog = DataDogWrapper( + base_url=data_dog_config.base_url, api_key=data_dog_config.api_key + ) + event_data = data_dog.generate_event_data( + log=instance.log, + email=instance.author.email if instance.author else "", + environment_name=instance.environment.name.lower() + if instance.environment + else "", + ) data_dog.track_event_async(event=event_data) diff --git a/src/audit/tests/test_models.py b/src/audit/tests/test_models.py index a473870f12ef..1df5f2a5befd 100644 --- a/src/audit/tests/test_models.py +++ b/src/audit/tests/test_models.py @@ -1,6 +1,7 @@ from unittest import TestCase, mock import pytest + from audit.models import AuditLog, RelatedObjectType from integrations.datadog.models import DataDogConfiguration from organisations.models import Organisation @@ -10,13 +11,17 @@ @pytest.mark.django_db class AuditLogTestCase(TestCase): def setUp(self) -> None: - self.organisation = Organisation.objects.create(name='Test Organisation') - self.project = Project.objects.create(name='Test project', organisation=self.organisation) - - @mock.patch('audit.signals.call_organisation_webhooks') - def test_organisation_webhooks_are_called_when_audit_log_saved(self, mock_call_webhooks): + self.organisation = Organisation.objects.create(name="Test Organisation") + self.project = Project.objects.create( + name="Test project", organisation=self.organisation + ) + + @mock.patch("audit.signals.call_organisation_webhooks") + def test_organisation_webhooks_are_called_when_audit_log_saved( + self, mock_call_webhooks + ): # Given - audit_log = AuditLog(project=self.project, log='Some audit log') + audit_log = AuditLog(project=self.project, log="Some audit log") # When audit_log.save() @@ -24,10 +29,12 @@ def test_organisation_webhooks_are_called_when_audit_log_saved(self, mock_call_w # Then mock_call_webhooks.assert_called() - @mock.patch('integrations.datadog.datadog.DataDogWrapper.track_event_async') - def test_data_dog_track_event_not_called_on_audit_log_saved_when_not_configured(self, datadog_mock): + @mock.patch("integrations.datadog.datadog.DataDogWrapper.track_event_async") + def test_data_dog_track_event_not_called_on_audit_log_saved_when_not_configured( + self, datadog_mock + ): # Given Audit log and project not configured for Datadog - audit_log = AuditLog(project=self.project, log='Some audit log') + audit_log = AuditLog(project=self.project, log="Some audit log") # When Audit log saved audit_log.save() @@ -35,16 +42,21 @@ def test_data_dog_track_event_not_called_on_audit_log_saved_when_not_configured( # Then datadog track even should not be triggered datadog_mock.track_event_async.assert_not_called() - @mock.patch('integrations.datadog.datadog.DataDogWrapper.track_event_async') - def test_data_dog_track_event_not_called_on_audit_log_saved_when_wrong(self, datadog_mock): + @mock.patch("integrations.datadog.datadog.DataDogWrapper.track_event_async") + def test_data_dog_track_event_not_called_on_audit_log_saved_when_wrong( + self, datadog_mock + ): # Given Audit log and project configured for Datadog integration - DataDogConfiguration.objects.create(project=self.project, - base_url='http"//test.com', - api_key='123key') + DataDogConfiguration.objects.create( + project=self.project, base_url='http"//test.com', api_key="123key" + ) - audit_log = AuditLog(project=self.project, log='Some audit log') - audit_log2 = AuditLog(project=self.project, log='Some audit log', - related_object_type=RelatedObjectType.ENVIRONMENT.name) + audit_log = AuditLog(project=self.project, log="Some audit log") + audit_log2 = AuditLog( + project=self.project, + log="Some audit log", + related_object_type=RelatedObjectType.ENVIRONMENT.name, + ) # When Audit log saved with wrong types audit_log.save() @@ -53,20 +65,31 @@ def test_data_dog_track_event_not_called_on_audit_log_saved_when_wrong(self, dat # Then datadog track even should not be triggered datadog_mock.track_event_async.assert_not_called() - @mock.patch('integrations.datadog.datadog.DataDogWrapper.track_event_async') - def test_data_dog_track_event_called_on_audit_log_saved_when_correct_type(self, datadog_mock): + @mock.patch("integrations.datadog.datadog.DataDogWrapper.track_event_async") + def test_data_dog_track_event_called_on_audit_log_saved_when_correct_type( + self, datadog_mock + ): # Given project configured for Datadog integration - DataDogConfiguration.objects.create(project=self.project, - base_url='http"//test.com', - api_key='123key') + DataDogConfiguration.objects.create( + project=self.project, base_url='http"//test.com', api_key="123key" + ) # When Audit logs created with correct type - AuditLog.objects.create(project=self.project, log='Some audit log', - related_object_type=RelatedObjectType.FEATURE.name) - AuditLog.objects.create(project=self.project, log='Some audit log', - related_object_type=RelatedObjectType.FEATURE_STATE.name) - AuditLog.objects.create(project=self.project, log='Some audit log', - related_object_type=RelatedObjectType.SEGMENT.name) + AuditLog.objects.create( + project=self.project, + log="Some audit log", + related_object_type=RelatedObjectType.FEATURE.name, + ) + AuditLog.objects.create( + project=self.project, + log="Some audit log", + related_object_type=RelatedObjectType.FEATURE_STATE.name, + ) + AuditLog.objects.create( + project=self.project, + log="Some audit log", + related_object_type=RelatedObjectType.SEGMENT.name, + ) # Then datadog track even triggered for each AuditLog assert 3 == datadog_mock.call_count diff --git a/src/audit/urls.py b/src/audit/urls.py index 3ec5f2b99a51..219e1926a4d0 100644 --- a/src/audit/urls.py +++ b/src/audit/urls.py @@ -1,12 +1,10 @@ -from django.conf.urls import url, include +from django.conf.urls import include, url from rest_framework import routers from audit.views import AuditLogViewSet router = routers.DefaultRouter() -router.register(r'', AuditLogViewSet, basename='audit') +router.register(r"", AuditLogViewSet, basename="audit") -urlpatterns = [ - url(r'^', include(router.urls)) -] \ No newline at end of file +urlpatterns = [url(r"^", include(router.urls))] diff --git a/src/audit/views.py b/src/audit/views.py index fcc905d56f69..6c112d2944a8 100644 --- a/src/audit/views.py +++ b/src/audit/views.py @@ -3,31 +3,46 @@ from django.utils.decorators import method_decorator from drf_yasg2 import openapi from drf_yasg2.utils import swagger_auto_schema -from rest_framework import viewsets, mixins +from rest_framework import mixins, viewsets from audit.models import AuditLog from audit.serializers import AuditLogSerializer -project_query_param = openapi.Parameter('project', openapi.IN_QUERY, description='ID of the project to filter on', - type=openapi.TYPE_INTEGER) -environment_query_param = openapi.Parameter('environment', openapi.IN_QUERY, - description='ID of the environment to filter on ' - '(Note `id` required, not `api_key`)', - type=openapi.TYPE_INTEGER) +project_query_param = openapi.Parameter( + "project", + openapi.IN_QUERY, + description="ID of the project to filter on", + type=openapi.TYPE_INTEGER, +) +environment_query_param = openapi.Parameter( + "environment", + openapi.IN_QUERY, + description="ID of the environment to filter on " + "(Note `id` required, not `api_key`)", + type=openapi.TYPE_INTEGER, +) -@method_decorator(name='list', decorator=swagger_auto_schema( - manual_parameters=[project_query_param, environment_query_param])) +@method_decorator( + name="list", + decorator=swagger_auto_schema( + manual_parameters=[project_query_param, environment_query_param] + ), +) class AuditLogViewSet(mixins.ListModelMixin, viewsets.GenericViewSet): serializer_class = AuditLogSerializer def get_queryset(self): q = Q(project__organisation__in=self.request.user.organisations.all()) - if 'project' in self.request.query_params: - project_id = self._get_value_as_int(self.request.query_params.get('project')) + if "project" in self.request.query_params: + project_id = self._get_value_as_int( + self.request.query_params.get("project") + ) q = q & Q(project__id=project_id) - if 'environment' in self.request.query_params: - environment_id = self._get_value_as_int(self.request.query_params.get('environment')) + if "environment" in self.request.query_params: + environment_id = self._get_value_as_int( + self.request.query_params.get("environment") + ) q = q & (Q(environment__id=environment_id) | Q(environment=None)) return AuditLog.objects.filter(q) diff --git a/src/custom_auth/mfa/backends/application.py b/src/custom_auth/mfa/backends/application.py index 11903d6f92a0..3d81db4d4000 100644 --- a/src/custom_auth/mfa/backends/application.py +++ b/src/custom_auth/mfa/backends/application.py @@ -4,8 +4,5 @@ class CustomApplicationBackend(ApplicationBackend): def dispatch_message(self): original_message = super(CustomApplicationBackend, self).dispatch_message() - data = { - **original_message, - "secret": self.obj.secret - } + data = {**original_message, "secret": self.obj.secret} return data diff --git a/src/custom_auth/oauth/github.py b/src/custom_auth/oauth/github.py index ee0b36a6b192..d1de8eca47c5 100644 --- a/src/custom_auth/oauth/github.py +++ b/src/custom_auth/oauth/github.py @@ -3,7 +3,10 @@ from requests import RequestException from custom_auth.oauth.exceptions import GithubError -from custom_auth.oauth.helpers.github_helpers import convert_response_data_to_dictionary, get_first_and_last_name +from custom_auth.oauth.helpers.github_helpers import ( + convert_response_data_to_dictionary, + get_first_and_last_name, +) from util.logging import get_logger GITHUB_API_URL = "https://api.github.com" @@ -20,15 +23,13 @@ def __init__(self, code: str, client_id: str = None, client_secret: str = None): self.client_secret = client_secret or settings.GITHUB_CLIENT_SECRET self.access_token = self._get_access_token(code) - self.headers = { - "Authorization": f"token {self.access_token}" - } + self.headers = {"Authorization": f"token {self.access_token}"} def _get_access_token(self, code) -> str: data = { "code": code, "client_id": self.client_id, - "client_secret": self.client_secret + "client_secret": self.client_secret, } response = requests.post(f"{GITHUB_OAUTH_URL}/access_token", data=data) @@ -45,10 +46,7 @@ def _get_access_token(self, code) -> str: def get_user_info(self) -> dict: # TODO: use threads? try: - return { - **self._get_user_name_and_id(), - "email": self._get_primary_email() - } + return {**self._get_user_name_and_id(), "email": self._get_primary_email()} except RequestException: raise GithubError("Failed to communicate with the Github API.") @@ -56,23 +54,33 @@ def _get_user_name_and_id(self): user_response = requests.get(f"{GITHUB_API_URL}/user", headers=self.headers) user_response_json = user_response.json() full_name = user_response_json.get("name") - first_name, last_name = get_first_and_last_name(full_name) if full_name else ["", ""] + first_name, last_name = ( + get_first_and_last_name(full_name) if full_name else ["", ""] + ) return { "first_name": first_name, "last_name": last_name, - "github_user_id": user_response_json.get("id") + "github_user_id": user_response_json.get("id"), } def _get_primary_email(self): - emails_response = requests.get(f"{GITHUB_API_URL}/user/emails", headers=self.headers) + emails_response = requests.get( + f"{GITHUB_API_URL}/user/emails", headers=self.headers + ) # response from github should be a list of dictionaries, this will find the first entry that is both verified # and marked as primary (there should only be one). primary_email_data = next( - filter(lambda email_data: email_data["primary"] and email_data["verified"], emails_response.json()), None + filter( + lambda email_data: email_data["primary"] and email_data["verified"], + emails_response.json(), + ), + None, ) if not primary_email_data: - raise GithubError("User does not have a verified email address with Github.") + raise GithubError( + "User does not have a verified email address with Github." + ) return primary_email_data["email"] diff --git a/src/custom_auth/oauth/google.py b/src/custom_auth/oauth/google.py index 1467ad073864..5c8400b8a588 100644 --- a/src/custom_auth/oauth/google.py +++ b/src/custom_auth/oauth/google.py @@ -21,7 +21,7 @@ def get_user_info(access_token): "email": response_json["email"], "first_name": response_json.get("given_name", ""), "last_name": response_json.get("family_name", ""), - "google_user_id": response_json["id"] + "google_user_id": response_json["id"], } except RequestException: raise GoogleError("Failed to communicate with the Google API.") diff --git a/src/custom_auth/oauth/helpers/tests/test_unit_github_helpers.py b/src/custom_auth/oauth/helpers/tests/test_unit_github_helpers.py index a2f971118d22..e62f3a26fe35 100644 --- a/src/custom_auth/oauth/helpers/tests/test_unit_github_helpers.py +++ b/src/custom_auth/oauth/helpers/tests/test_unit_github_helpers.py @@ -1,7 +1,10 @@ import pytest from custom_auth.oauth.exceptions import GithubError -from custom_auth.oauth.helpers.github_helpers import convert_response_data_to_dictionary, get_first_and_last_name +from custom_auth.oauth.helpers.github_helpers import ( + convert_response_data_to_dictionary, + get_first_and_last_name, +) def test_convert_response_data_to_dictionary_success(): diff --git a/src/custom_auth/oauth/serializers.py b/src/custom_auth/oauth/serializers.py index 009b6795650d..341e56f14398 100644 --- a/src/custom_auth/oauth/serializers.py +++ b/src/custom_auth/oauth/serializers.py @@ -12,7 +12,7 @@ class OAuthLoginSerializer(serializers.Serializer): access_token = serializers.CharField( required=True, - help_text="Code or access token returned from the FE interaction with the third party login provider." + help_text="Code or access token returned from the FE interaction with the third party login provider.", ) class Meta: diff --git a/src/custom_auth/oauth/tests/test_unit_github.py b/src/custom_auth/oauth/tests/test_unit_github.py index f63f1eda3728..e60ba880c5c6 100644 --- a/src/custom_auth/oauth/tests/test_unit_github.py +++ b/src/custom_auth/oauth/tests/test_unit_github.py @@ -1,4 +1,4 @@ -from unittest import mock, TestCase +from unittest import TestCase, mock import pytest @@ -22,11 +22,16 @@ def test_get_access_token_success(self): expected_access_token = "access-token" self.mock_requests.post.return_value = mock.MagicMock( - text=f"access_token={expected_access_token}&scope=user&token_type=bearer", status_code=200 + text=f"access_token={expected_access_token}&scope=user&token_type=bearer", + status_code=200, ) # When - github_user = GithubUser(test_code, client_id=self.test_client_id, client_secret=self.test_client_secret) + github_user = GithubUser( + test_code, + client_id=self.test_client_id, + client_secret=self.test_client_secret, + ) # Then assert github_user.access_token == expected_access_token @@ -43,7 +48,11 @@ def test_get_access_token_fail_non_200(self): # When with pytest.raises(GithubError) as e: - GithubUser(invalid_code, client_id=self.test_client_id, client_secret=self.test_client_secret) + GithubUser( + invalid_code, + client_id=self.test_client_id, + client_secret=self.test_client_secret, + ) # Then - exception raised assert NON_200_ERROR_MESSAGE.format(status_code) in str(e) @@ -54,12 +63,17 @@ def test_get_access_token_fail_token_expired(self): error_description = "there+was+an+error" self.mock_requests.post.return_value = mock.MagicMock( - text=f"error=bad_verification_code&error_description={error_description}", status_code=200 + text=f"error=bad_verification_code&error_description={error_description}", + status_code=200, ) # When with pytest.raises(GithubError) as e: - GithubUser(invalid_code, client_id=self.test_client_id, client_secret=self.test_client_secret) + GithubUser( + invalid_code, + client_id=self.test_client_id, + client_secret=self.test_client_secret, + ) # Then assert error_description.replace("+", " ") in str(e) @@ -67,42 +81,50 @@ def test_get_access_token_fail_token_expired(self): def test_get_user_name_and_id(self): # Given # mock the post to get the access token - self.mock_requests.post.return_value = mock.MagicMock(status_code=200, text="access_token=123456") + self.mock_requests.post.return_value = mock.MagicMock( + status_code=200, text="access_token=123456" + ) # mock the get to get the user info mock_response = mock.MagicMock(status_code=200) self.mock_requests.get.return_value = mock_response - mock_response.json.return_value = { - "name": "tommy tester", - "id": 123456 - } + mock_response.json.return_value = {"name": "tommy tester", "id": 123456} # When - github_user = GithubUser("test-code", client_id=self.test_client_id, client_secret=self.test_client_secret) + github_user = GithubUser( + "test-code", + client_id=self.test_client_id, + client_secret=self.test_client_secret, + ) user_name_and_id = github_user._get_user_name_and_id() # Then assert user_name_and_id == { "first_name": "tommy", "last_name": "tester", - "github_user_id": 123456 + "github_user_id": 123456, } def test_get_primary_email(self): # Given # mock the post to get the access token - self.mock_requests.post.return_value = mock.MagicMock(status_code=200, text="access_token=123456") + self.mock_requests.post.return_value = mock.MagicMock( + status_code=200, text="access_token=123456" + ) # mock the request to get the user info mock_response = mock.MagicMock(status_code=200) self.mock_requests.get.return_value = mock_response - verified_emails = [{ - "email": f"tommy_tester@example_{i}.com", - "verified": True, - "visibility": None, - "primary": False - } for i in range(5)] + verified_emails = [ + { + "email": f"tommy_tester@example_{i}.com", + "verified": True, + "visibility": None, + "primary": False, + } + for i in range(5) + ] # set one of the verified emails to be the primary verified_emails[3]["primary"] = True @@ -110,7 +132,11 @@ def test_get_primary_email(self): mock_response.json.return_value = verified_emails # When - github_user = GithubUser("test-code", client_id=self.test_client_id, client_secret=self.test_client_secret) + github_user = GithubUser( + "test-code", + client_id=self.test_client_id, + client_secret=self.test_client_secret, + ) primary_email = github_user._get_primary_email() # Then diff --git a/src/custom_auth/oauth/tests/test_unit_google.py b/src/custom_auth/oauth/tests/test_unit_google.py index 08157f7895ef..970815466828 100644 --- a/src/custom_auth/oauth/tests/test_unit_google.py +++ b/src/custom_auth/oauth/tests/test_unit_google.py @@ -3,7 +3,7 @@ import pytest from custom_auth.oauth.exceptions import GoogleError -from custom_auth.oauth.google import get_user_info, USER_INFO_URL +from custom_auth.oauth.google import USER_INFO_URL, get_user_info @mock.patch("custom_auth.oauth.google.requests") @@ -14,7 +14,7 @@ def test_get_user_info(mock_requests): "id": "test-id", "given_name": "testy", "family_name": "tester", - "email": "testytester@example.com" + "email": "testytester@example.com", } expected_headers = {"Authorization": f"Bearer {access_token}"} mock_response = mock.MagicMock(status_code=200) @@ -30,7 +30,7 @@ def test_get_user_info(mock_requests): "email": mock_google_response_data["email"], "first_name": mock_google_response_data["given_name"], "last_name": mock_google_response_data["family_name"], - "google_user_id": mock_google_response_data["id"] + "google_user_id": mock_google_response_data["id"], } diff --git a/src/custom_auth/oauth/tests/test_unit_serializers.py b/src/custom_auth/oauth/tests/test_unit_serializers.py index ef5fc95e8516..186606febbf2 100644 --- a/src/custom_auth/oauth/tests/test_unit_serializers.py +++ b/src/custom_auth/oauth/tests/test_unit_serializers.py @@ -4,7 +4,11 @@ from django.contrib.auth import get_user_model from rest_framework.authtoken.models import Token -from custom_auth.oauth.serializers import GoogleLoginSerializer, OAuthLoginSerializer, GithubLoginSerializer +from custom_auth.oauth.serializers import ( + GithubLoginSerializer, + GoogleLoginSerializer, + OAuthLoginSerializer, +) UserModel = get_user_model() @@ -20,16 +24,14 @@ def setUp(self) -> None: "email": self.test_email, "first_name": self.test_first_name, "last_name": self.test_last_name, - "google_user_id": self.test_id + "google_user_id": self.test_id, } @mock.patch("custom_auth.oauth.serializers.get_user_info") def test_create(self, mock_get_user_info): # Given access_token = "access-token" - data = { - "access_token": access_token - } + data = {"access_token": access_token} serializer = OAuthLoginSerializer(data=data) # monkey patch the get_user_info method to return the mock user data diff --git a/src/custom_auth/oauth/urls.py b/src/custom_auth/oauth/urls.py index 90c5bf3d40d6..dfb3f029c7e0 100644 --- a/src/custom_auth/oauth/urls.py +++ b/src/custom_auth/oauth/urls.py @@ -1,10 +1,7 @@ from django.urls import path -from custom_auth.oauth.views import login_with_google, login_with_github +from custom_auth.oauth.views import login_with_github, login_with_google app_name = "oauth" -urlpatterns = [ - path("google/", login_with_google), - path("github/", login_with_github) -] +urlpatterns = [path("google/", login_with_google), path("github/", login_with_github)] diff --git a/src/custom_auth/permissions.py b/src/custom_auth/permissions.py index 337e7c53624d..125f65290341 100644 --- a/src/custom_auth/permissions.py +++ b/src/custom_auth/permissions.py @@ -5,6 +5,7 @@ class CurrentUser(IsAuthenticated): """ Class to ensure that users of the platform can only retrieve details of themselves. """ + def has_permission(self, request, view): return view.action == "me" diff --git a/src/custom_auth/serializers.py b/src/custom_auth/serializers.py index f2eb8c4bc8d3..3471944da9e9 100644 --- a/src/custom_auth/serializers.py +++ b/src/custom_auth/serializers.py @@ -15,8 +15,8 @@ def __init__(self, *args, **kwargs): self.fields["key"] = serializers.SerializerMethodField() class Meta(UserCreateSerializer.Meta): - fields = UserCreateSerializer.Meta.fields + ('is_active',) - read_only_fields = ('is_active',) + fields = UserCreateSerializer.Meta.fields + ("is_active",) + read_only_fields = ("is_active",) @staticmethod def get_key(instance): diff --git a/src/custom_auth/tests/end_to_end/test_custom_auth_integration.py b/src/custom_auth/tests/end_to_end/test_custom_auth_integration.py index fab57074fec4..37510feaa594 100644 --- a/src/custom_auth/tests/end_to_end/test_custom_auth_integration.py +++ b/src/custom_auth/tests/end_to_end/test_custom_auth_integration.py @@ -34,9 +34,7 @@ def test_register_and_login_workflows(self): # now register with full data register_data["first_name"] = "test" register_data["last_name"] = "user" - register_response_success = self.client.post( - register_url, data=register_data - ) + register_response_success = self.client.post(register_url, data=register_data) assert register_response_success.status_code == status.HTTP_201_CREATED assert register_response_success.json()["key"] @@ -76,7 +74,9 @@ def test_register_and_login_workflows(self): "new_password": new_password, "re_new_password": new_password, } - reset_password_confirm_url = reverse("api-v1:custom_auth:ffadminuser-reset-password-confirm") + reset_password_confirm_url = reverse( + "api-v1:custom_auth:ffadminuser-reset-password-confirm" + ) reset_password_confirm_response = self.client.post( reset_password_confirm_url, data=reset_password_confirm_data ) @@ -95,11 +95,8 @@ def test_register_and_login_workflows(self): @override_settings( DJOSER=ChainMap( - { - 'SEND_ACTIVATION_EMAIL': True, - 'SEND_CONFIRMATION_EMAIL': False - }, - settings.DJOSER + {"SEND_ACTIVATION_EMAIL": True, "SEND_CONFIRMATION_EMAIL": False}, + settings.DJOSER, ) ) def test_registration_and_login_with_user_activation_flow(self): @@ -118,15 +115,17 @@ def test_registration_and_login_with_user_activation_flow(self): # When register register_url = reverse("api-v1:custom_auth:ffadminuser-list") - result = self.client.post(register_url, data=register_data, status_code=status.HTTP_201_CREATED) + result = self.client.post( + register_url, data=register_data, status_code=status.HTTP_201_CREATED + ) # Then success and account inactive - self.assertIn('key', result.data) - self.assertIn('is_active', result.data) - assert result.data['is_active'] == False + self.assertIn("key", result.data) + self.assertIn("is_active", result.data) + assert result.data["is_active"] == False - new_user = FFAdminUser.objects.latest('id') - self.assertEqual(new_user.email, register_data['email']) + new_user = FFAdminUser.objects.latest("id") + self.assertEqual(new_user.email, register_data["email"]) self.assertFalse(new_user.is_active) # And login should fail as we have not activated account yet @@ -150,20 +149,19 @@ def test_registration_and_login_with_user_activation_flow(self): uid = split_url[-2] token = split_url[-1] - activate_data = { - "uid": uid, - "token": token - } + activate_data = {"uid": uid, "token": token} activate_url = reverse("api-v1:custom_auth:ffadminuser-activation") # And activate account - self.client.post(activate_url, data=activate_data, status_code=status.HTTP_204_NO_CONTENT) + self.client.post( + activate_url, data=activate_data, status_code=status.HTTP_204_NO_CONTENT + ) time.sleep(1) # And login success login_result = self.client.post(login_url, data=login_data) assert login_result.status_code == status.HTTP_200_OK - self.assertIn('key', login_result.data) + self.assertIn("key", login_result.data) def test_login_workflow_with_mfa_enabled(self): # register the user @@ -175,9 +173,7 @@ def test_login_workflow_with_mfa_enabled(self): "last_name": "user", } register_url = reverse("api-v1:custom_auth:ffadminuser-list") - register_response = self.client.post( - register_url, data=register_data - ) + register_response = self.client.post(register_url, data=register_data) assert register_response.status_code == status.HTTP_201_CREATED key = register_response.json()["key"] @@ -185,25 +181,26 @@ def test_login_workflow_with_mfa_enabled(self): self.client.credentials(HTTP_AUTHORIZATION=f"Token {key}") # create an MFA method - create_mfa_method_url = reverse("api-v1:custom_auth:mfa-activate", kwargs={"method": "app"}) + create_mfa_method_url = reverse( + "api-v1:custom_auth:mfa-activate", kwargs={"method": "app"} + ) create_mfa_response = self.client.post(create_mfa_method_url) assert create_mfa_response.status_code == status.HTTP_200_OK secret = create_mfa_response.json()["secret"] # confirm the MFA method totp = pyotp.TOTP(secret) - confirm_mfa_data = { - "code": totp.now() - } - confirm_mfa_method_url = reverse("api-v1:custom_auth:mfa-activate-confirm", kwargs={"method": "app"}) - confirm_mfa_method_response = self.client.post(confirm_mfa_method_url, data=confirm_mfa_data) + confirm_mfa_data = {"code": totp.now()} + confirm_mfa_method_url = reverse( + "api-v1:custom_auth:mfa-activate-confirm", kwargs={"method": "app"} + ) + confirm_mfa_method_response = self.client.post( + confirm_mfa_method_url, data=confirm_mfa_data + ) assert confirm_mfa_method_response # now login should return an ephemeral token rather than a token - login_data = { - "email": self.test_email, - "password": self.password - } + login_data = {"email": self.test_email, "password": self.password} self.client.logout() login_url = reverse("api-v1:custom_auth:custom-mfa-authtoken-login") login_response = self.client.post(login_url, data=login_data) @@ -211,12 +208,11 @@ def test_login_workflow_with_mfa_enabled(self): ephemeral_token = login_response.json()["ephemeral_token"] # now we can confirm the login - confirm_login_data = { - "ephemeral_token": ephemeral_token, - "code": totp.now() - } + confirm_login_data = {"ephemeral_token": ephemeral_token, "code": totp.now()} login_confirm_url = reverse("api-v1:custom_auth:mfa-authtoken-login-code") - login_confirm_response = self.client.post(login_confirm_url, data=confirm_login_data) + login_confirm_response = self.client.post( + login_confirm_url, data=confirm_login_data + ) assert login_confirm_response.status_code == status.HTTP_200_OK key = login_confirm_response.json()["key"] @@ -237,9 +233,7 @@ def test_throttle_login_workflows(self): "last_name": "user", } register_url = reverse("api-v1:custom_auth:ffadminuser-list") - register_response = self.client.post( - register_url, data=register_data - ) + register_response = self.client.post(register_url, data=register_data) assert register_response.status_code == status.HTTP_201_CREATED assert register_response.json()["key"] diff --git a/src/custom_auth/urls.py b/src/custom_auth/urls.py index d9a685bebe84..89af97f26fc2 100644 --- a/src/custom_auth/urls.py +++ b/src/custom_auth/urls.py @@ -1,14 +1,19 @@ from django.urls import include, path + from custom_auth.views import CustomAuthTokenLoginOrRequestMFACode -app_name = 'custom_auth' +app_name = "custom_auth" urlpatterns = [ # Override auth/login endpoint for throttling login requests - path('login/', CustomAuthTokenLoginOrRequestMFACode.as_view(), name='custom-mfa-authtoken-login'), - path('', include('djoser.urls')), - path('', include('trench.urls')), # MFA - path('', include('trench.urls.djoser')), # override necessary urls for MFA auth - path('oauth/', include('custom_auth.oauth.urls')), + path( + "login/", + CustomAuthTokenLoginOrRequestMFACode.as_view(), + name="custom-mfa-authtoken-login", + ), + path("", include("djoser.urls")), + path("", include("trench.urls")), # MFA + path("", include("trench.urls.djoser")), # override necessary urls for MFA auth + path("oauth/", include("custom_auth.oauth.urls")), ] diff --git a/src/custom_auth/views.py b/src/custom_auth/views.py index 376a5462a0ff..08f39a0e7ac1 100644 --- a/src/custom_auth/views.py +++ b/src/custom_auth/views.py @@ -6,5 +6,6 @@ class CustomAuthTokenLoginOrRequestMFACode(AuthTokenLoginOrRequestMFACode): """ Class to handle throttling for login requests """ + throttle_classes = [ScopedRateThrottle] - throttle_scope = 'login' + throttle_scope = "login" diff --git a/src/e2etests/tests/end_to_end/test_integration_e2e_tests.py b/src/e2etests/tests/end_to_end/test_integration_e2e_tests.py index 190deaab5c0c..666ec1debc78 100644 --- a/src/e2etests/tests/end_to_end/test_integration_e2e_tests.py +++ b/src/e2etests/tests/end_to_end/test_integration_e2e_tests.py @@ -28,7 +28,7 @@ def test_e2e_teardown(self): "first_name": "test", "last_name": "test", "password": test_password, - "re_password": test_password + "re_password": test_password, } register_response = self.client.post(self.register_url, data=register_data) assert register_response.status_code == status.HTTP_201_CREATED diff --git a/src/e2etests/urls.py b/src/e2etests/urls.py index 9000bed5f02a..ddbb90b73058 100644 --- a/src/e2etests/urls.py +++ b/src/e2etests/urls.py @@ -5,6 +5,4 @@ app_name = "e2etests" -urlpatterns = [ - url(r'teardown/', Teardown.as_view(), name='teardown') -] \ No newline at end of file +urlpatterns = [url(r"teardown/", Teardown.as_view(), name="teardown")] diff --git a/src/e2etests/views.py b/src/e2etests/views.py index a0ce11611a45..daa8c2ef1458 100644 --- a/src/e2etests/views.py +++ b/src/e2etests/views.py @@ -15,19 +15,23 @@ class Teardown(APIView): authentication_classes = (TokenAuthentication,) def post(self, request): - if 'HTTP_X_E2E_TEST_AUTH_TOKEN' not in request.META or \ - 'E2E_TEST_AUTH_TOKEN' not in os.environ: + if ( + "HTTP_X_E2E_TEST_AUTH_TOKEN" not in request.META + or "E2E_TEST_AUTH_TOKEN" not in os.environ + ): return Response(status=status.HTTP_401_UNAUTHORIZED) - auth_key = request.META['HTTP_X_E2E_TEST_AUTH_TOKEN'] - allowed_access_key = os.environ['E2E_TEST_AUTH_TOKEN'] + auth_key = request.META["HTTP_X_E2E_TEST_AUTH_TOKEN"] + allowed_access_key = os.environ["E2E_TEST_AUTH_TOKEN"] if auth_key != allowed_access_key: error = {"detail": "Bad authentication token"} return Response(error, status=status.HTTP_401_UNAUTHORIZED) # delete users created for e2e test by front end - if os.environ['FE_E2E_TEST_USER_EMAIL']: - FFAdminUser.objects.filter(email=os.environ['FE_E2E_TEST_USER_EMAIL']).delete() + if os.environ["FE_E2E_TEST_USER_EMAIL"]: + FFAdminUser.objects.filter( + email=os.environ["FE_E2E_TEST_USER_EMAIL"] + ).delete() return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/src/environments/__init__.py b/src/environments/__init__.py index c224ea94d226..39086e255972 100644 --- a/src/environments/__init__.py +++ b/src/environments/__init__.py @@ -1 +1 @@ -default_app_config = 'environments.apps.EnvironmentsConfig' +default_app_config = "environments.apps.EnvironmentsConfig" diff --git a/src/environments/admin.py b/src/environments/admin.py index e908ffc348ab..2d15ea485cae 100644 --- a/src/environments/admin.py +++ b/src/environments/admin.py @@ -5,8 +5,8 @@ from django.contrib import admin from .identities.traits.admin import TraitAdmin -from .models import Environment, Webhook from .identities.traits.models import Trait +from .models import Environment, Webhook class WebhookInline(admin.TabularInline): @@ -16,8 +16,19 @@ class WebhookInline(admin.TabularInline): @admin.register(Environment) class EnvironmentAdmin(admin.ModelAdmin): - date_hierarchy = 'created_date' - list_display = ('name', '__str__', 'created_date',) - list_filter = ('created_date', 'project',) - search_fields = ('name', 'project__name', 'api_key',) + date_hierarchy = "created_date" + list_display = ( + "name", + "__str__", + "created_date", + ) + list_filter = ( + "created_date", + "project", + ) + search_fields = ( + "name", + "project__name", + "api_key", + ) inlines = (WebhookInline,) diff --git a/src/environments/apps.py b/src/environments/apps.py index 2d42964f3e29..389446ea9e52 100644 --- a/src/environments/apps.py +++ b/src/environments/apps.py @@ -5,4 +5,4 @@ class EnvironmentsConfig(AppConfig): - name = 'environments' + name = "environments" diff --git a/src/environments/authentication.py b/src/environments/authentication.py index 4d7b9fa3774d..2a22a629af25 100644 --- a/src/environments/authentication.py +++ b/src/environments/authentication.py @@ -12,15 +12,16 @@ class EnvironmentKeyAuthentication(BaseAuthentication): """ Custom authentication class to add the environment to the request for endpoints used by the clients. """ + def authenticate(self, request): try: - api_key = request.META.get('HTTP_X_ENVIRONMENT_KEY') + api_key = request.META.get("HTTP_X_ENVIRONMENT_KEY") environment = Environment.get_from_cache(api_key) except Environment.DoesNotExist: - raise AuthenticationFailed('Invalid or missing Environment Key') + raise AuthenticationFailed("Invalid or missing Environment Key") if not self._can_serve_flags(environment): - raise AuthenticationFailed('Organisation is disabled from serving flags.') + raise AuthenticationFailed("Organisation is disabled from serving flags.") request.environment = environment diff --git a/src/environments/exceptions.py b/src/environments/exceptions.py index 2d9fca9d075b..f5a40335f227 100644 --- a/src/environments/exceptions.py +++ b/src/environments/exceptions.py @@ -1,4 +1,2 @@ class EnvironmentHeaderNotPresentError(Exception): pass - - diff --git a/src/environments/identities/tests/test_models.py b/src/environments/identities/tests/test_models.py index 3c0090850812..eb5d19ce3d2e 100644 --- a/src/environments/identities/tests/test_models.py +++ b/src/environments/identities/tests/test_models.py @@ -2,23 +2,24 @@ from environments.identities.models import Identity from environments.identities.traits.models import Trait -from environments.models import Environment, FLOAT -from features.models import Feature, FeatureState, FeatureSegment, CONFIG -from features.utils import STRING, INTEGER, BOOLEAN +from environments.models import FLOAT, Environment +from features.models import CONFIG, Feature, FeatureSegment, FeatureState +from features.utils import BOOLEAN, INTEGER, STRING from organisations.models import Organisation from projects.models import Project from segments.models import ( - Segment, - SegmentRule, - Condition, EQUAL, - GREATER_THAN_INCLUSIVE, GREATER_THAN, + GREATER_THAN_INCLUSIVE, LESS_THAN_INCLUSIVE, + Condition, + Segment, + SegmentRule, ) + from .helpers import ( - generate_trait_data_item, create_trait_for_identity, + generate_trait_data_item, get_trait_from_list_by_key, ) @@ -807,20 +808,38 @@ def test_update_traits_deletes_when_nulled_out(self): def test_get_segments(self): # Given # a segment with multiple rules and conditions - segment = Segment.objects.create(name='Test Segment', project=self.project) + segment = Segment.objects.create(name="Test Segment", project=self.project) - rule_one = SegmentRule.objects.create(segment=segment, type=SegmentRule.ANY_RULE) - Condition.objects.create(rule=rule_one, operator=EQUAL, property='foo', value='bar') - Condition.objects.create(rule=rule_one, operator=EQUAL, property='foo', value='baz') + rule_one = SegmentRule.objects.create( + segment=segment, type=SegmentRule.ANY_RULE + ) + Condition.objects.create( + rule=rule_one, operator=EQUAL, property="foo", value="bar" + ) + Condition.objects.create( + rule=rule_one, operator=EQUAL, property="foo", value="baz" + ) - rule_two = SegmentRule.objects.create(segment=segment, type=SegmentRule.ALL_RULE) - Condition.objects.create(rule=rule_two, operator=GREATER_THAN_INCLUSIVE, property='bar', value=10) - Condition.objects.create(rule=rule_two, operator=LESS_THAN_INCLUSIVE, property='bar', value=20) + rule_two = SegmentRule.objects.create( + segment=segment, type=SegmentRule.ALL_RULE + ) + Condition.objects.create( + rule=rule_two, operator=GREATER_THAN_INCLUSIVE, property="bar", value=10 + ) + Condition.objects.create( + rule=rule_two, operator=LESS_THAN_INCLUSIVE, property="bar", value=20 + ) # and an identity with traits that match the segment - identity = Identity.objects.create(identifier='identity-1', environment=self.environment) - Trait.objects.create(identity=identity, trait_key='bar', value_type=INTEGER, integer_value=15) - Trait.objects.create(identity=identity, trait_key='foo', value_type=STRING, string_value='bar') + identity = Identity.objects.create( + identifier="identity-1", environment=self.environment + ) + Trait.objects.create( + identity=identity, trait_key="bar", value_type=INTEGER, integer_value=15 + ) + Trait.objects.create( + identity=identity, trait_key="foo", value_type=STRING, string_value="bar" + ) # When # we get the matching segments for an identity @@ -830,4 +849,3 @@ def test_get_segments(self): # Then # the number of queries are what we expect (see above context manager) and the segment is returned assert len(segments) == 1 and segments[0] == segment - diff --git a/src/environments/identities/tests/test_views.py b/src/environments/identities/tests/test_views.py index e62f9b396f94..9ad24e8aebd0 100644 --- a/src/environments/identities/tests/test_views.py +++ b/src/environments/identities/tests/test_views.py @@ -10,12 +10,12 @@ from environments.identities.models import Identity from environments.identities.traits.models import Trait from environments.models import Environment -from features.models import Feature, FeatureState, FeatureSegment +from features.models import Feature, FeatureSegment, FeatureState from integrations.amplitude.models import AmplitudeConfiguration from organisations.models import Organisation, OrganisationRole from projects.models import Project from segments import models -from segments.models import Segment, SegmentRule, Condition +from segments.models import Condition, Segment, SegmentRule from util.tests import Helper @@ -291,13 +291,15 @@ def test_identities_endpoint_returns_all_feature_states_for_identity_if_feature_ # and assert len(response.json().get("flags")) == 2 - @mock.patch('integrations.amplitude.amplitude.AmplitudeWrapper.identify_user_async') + @mock.patch("integrations.amplitude.amplitude.AmplitudeWrapper.identify_user_async") def test_identities_endpoint_get_all_feature_amplitude_called( - self, mock_amplitude_wrapper + self, mock_amplitude_wrapper ): # Given # amplitude configuration for environment - AmplitudeConfiguration.objects.create(api_key="abc-123", environment=self.environment) + AmplitudeConfiguration.objects.create( + api_key="abc-123", environment=self.environment + ) base_url = reverse("api-v1:sdk-identities") url = base_url + "?identifier=" + self.identity.identifier @@ -313,7 +315,7 @@ def test_identities_endpoint_get_all_feature_amplitude_called( # and amplitude identify users should be called mock_amplitude_wrapper.assert_called() - @mock.patch('integrations.amplitude.amplitude.AmplitudeWrapper.identify_user_async') + @mock.patch("integrations.amplitude.amplitude.AmplitudeWrapper.identify_user_async") def test_identities_endpoint_returns_traits(self, mock_amplitude_wrapper): # Given base_url = reverse("api-v1:sdk-identities") @@ -360,8 +362,10 @@ def test_identities_endpoint_returns_single_feature_state_if_feature_provided(se # and assert response.json().get("feature").get("name") == self.feature_1.name - @mock.patch('integrations.amplitude.amplitude.AmplitudeWrapper.identify_user_async') - def test_identities_endpoint_returns_value_for_segment_if_identity_in_segment(self, mock_amplitude_wrapper): + @mock.patch("integrations.amplitude.amplitude.AmplitudeWrapper.identify_user_async") + def test_identities_endpoint_returns_value_for_segment_if_identity_in_segment( + self, mock_amplitude_wrapper + ): # Given base_url = reverse("api-v1:sdk-identities") url = base_url + "?identifier=" + self.identity.identifier @@ -401,9 +405,9 @@ def test_identities_endpoint_returns_value_for_segment_if_identity_in_segment(se # and amplitude identify users should not be called mock_amplitude_wrapper.assert_not_called() - @mock.patch('integrations.amplitude.amplitude.AmplitudeWrapper.identify_user_async') + @mock.patch("integrations.amplitude.amplitude.AmplitudeWrapper.identify_user_async") def test_identities_endpoint_returns_value_for_segment_if_identity_in_segment_and_feature_specified( - self, mock_amplitude_wrapper + self, mock_amplitude_wrapper ): # Given base_url = reverse("api-v1:sdk-identities") @@ -450,9 +454,9 @@ def test_identities_endpoint_returns_value_for_segment_if_identity_in_segment_an # and amplitude identify users should not be called mock_amplitude_wrapper.assert_not_called() - @mock.patch('integrations.amplitude.amplitude.AmplitudeWrapper.identify_user_async') + @mock.patch("integrations.amplitude.amplitude.AmplitudeWrapper.identify_user_async") def test_identities_endpoint_returns_value_for_segment_if_rule_type_percentage_split_and_identity_in_segment( - self, mock_amplitude_wrapper + self, mock_amplitude_wrapper ): # Given base_url = reverse("api-v1:sdk-identities") @@ -490,9 +494,9 @@ def test_identities_endpoint_returns_value_for_segment_if_rule_type_percentage_s # and amplitude identify users should not be called mock_amplitude_wrapper.assert_not_called() - @mock.patch('integrations.amplitude.amplitude.AmplitudeWrapper.identify_user_async') + @mock.patch("integrations.amplitude.amplitude.AmplitudeWrapper.identify_user_async") def test_identities_endpoint_returns_default_value_if_rule_type_percentage_split_and_identity_not_in_segment( - self, mock_amplitude_wrapper + self, mock_amplitude_wrapper ): # Given base_url = reverse("api-v1:sdk-identities") diff --git a/src/environments/identities/traits/tests/test_views.py b/src/environments/identities/traits/tests/test_views.py index efb474b9e9ff..f30faf30657b 100644 --- a/src/environments/identities/traits/tests/test_views.py +++ b/src/environments/identities/traits/tests/test_views.py @@ -4,11 +4,11 @@ import pytest from django.urls import reverse from rest_framework import status -from rest_framework.test import APITestCase, APIClient +from rest_framework.test import APIClient, APITestCase from environments.identities.models import Identity from environments.identities.traits.models import Trait -from environments.models import Environment, STRING, INTEGER +from environments.models import INTEGER, STRING, Environment from organisations.models import Organisation, OrganisationRole from projects.models import Project from util.tests import Helper @@ -92,10 +92,10 @@ def test_can_set_trait_with_boolean_value_for_an_identity(self): # and assert ( - Trait.objects.get( - identity=self.identity, trait_key=self.trait_key - ).get_trait_value() - == trait_value + Trait.objects.get( + identity=self.identity, trait_key=self.trait_key + ).get_trait_value() + == trait_value ) def test_can_set_trait_with_identity_value_for_an_identity(self): @@ -138,9 +138,10 @@ def test_can_set_trait_with_float_value_for_an_identity(self): # and assert ( - Trait.objects.get( - identity=self.identity, trait_key=self.trait_key - ).get_trait_value() == float_trait_value + Trait.objects.get( + identity=self.identity, trait_key=self.trait_key + ).get_trait_value() + == float_trait_value ) def test_add_trait_creates_identity_if_it_doesnt_exist(self): @@ -297,11 +298,9 @@ def test_can_set_trait_with_bad_value_for_an_identity(self): assert res.status_code == status.HTTP_200_OK # and - assert ( - Trait.objects.get( - identity=self.identity, trait_key=self.trait_key - ).get_trait_value() == str(bad_trait_value) - ) + assert Trait.objects.get( + identity=self.identity, trait_key=self.trait_key + ).get_trait_value() == str(bad_trait_value) def test_bulk_create_traits(self): # Given @@ -320,7 +319,9 @@ def test_bulk_create_traits(self): assert response.status_code == status.HTTP_200_OK assert Trait.objects.filter(identity=self.identity).count() == num_traits - def test_bulk_create_traits_when_bad_trait_value_sent_then_trait_value_stringified(self): + def test_bulk_create_traits_when_bad_trait_value_sent_then_trait_value_stringified( + self, + ): # Given num_traits = 5 url = reverse("api-v1:sdk-traits-bulk-create") @@ -335,7 +336,7 @@ def test_bulk_create_traits_when_bad_trait_value_sent_then_trait_value_stringifi { "trait_value": bad_trait_value, "trait_key": bad_trait_key, - "identity": {"identifier": self.identity.identifier} + "identity": {"identifier": self.identity.identifier}, } ) @@ -349,11 +350,9 @@ def test_bulk_create_traits_when_bad_trait_value_sent_then_trait_value_stringifi assert Trait.objects.filter(identity=self.identity).count() == num_traits + 1 # and - assert ( - Trait.objects.get( - identity=self.identity, trait_key=bad_trait_key - ).get_trait_value() == str(bad_trait_value) - ) + assert Trait.objects.get( + identity=self.identity, trait_key=bad_trait_key + ).get_trait_value() == str(bad_trait_value) def test_sending_null_value_in_bulk_create_deletes_trait_for_identity(self): # Given @@ -406,7 +405,7 @@ def test_bulk_create_traits_when_float_value_sent_then_trait_value_correct(self) { "trait_value": float_trait_value, "trait_key": float_trait_key, - "identity": {"identifier": self.identity.identifier} + "identity": {"identifier": self.identity.identifier}, } ) @@ -421,9 +420,10 @@ def test_bulk_create_traits_when_float_value_sent_then_trait_value_correct(self) # and assert ( - Trait.objects.get( - identity=self.identity, trait_key=float_trait_key - ).get_trait_value() == float_trait_value + Trait.objects.get( + identity=self.identity, trait_key=float_trait_key + ).get_trait_value() + == float_trait_value ) def _generate_trait_data(self, identifier=None, trait_key=None, trait_value=None): diff --git a/src/environments/identities/traits/views.py b/src/environments/identities/traits/views.py index a2e59a014527..fde60238ce32 100644 --- a/src/environments/identities/traits/views.py +++ b/src/environments/identities/traits/views.py @@ -2,7 +2,7 @@ from django.db.models import Q from drf_yasg2 import openapi from drf_yasg2.utils import swagger_auto_schema -from rest_framework import viewsets, status, mixins +from rest_framework import mixins, status, viewsets from rest_framework.decorators import action from rest_framework.response import Response from rest_framework.schemas import AutoSchema @@ -11,9 +11,9 @@ from environments.identities.models import Identity from environments.identities.traits.models import Trait from environments.identities.traits.serializers import ( - TraitSerializerFull, - TraitSerializerBasic, IncrementTraitValueSerializer, + TraitSerializerBasic, + TraitSerializerFull, ) from environments.models import Environment from environments.permissions.permissions import ( diff --git a/src/environments/identities/views.py b/src/environments/identities/views.py index 798d06dc0c51..79cff5f2068b 100644 --- a/src/environments/identities/views.py +++ b/src/environments/identities/views.py @@ -86,7 +86,8 @@ def get(self, request, identifier, *args, **kwargs): # if we have identifier fetch, or create if does not exist if identifier: identity, _ = Identity.objects.get_or_create( - identifier=identifier, environment=request.environment, + identifier=identifier, + environment=request.environment, ) else: @@ -181,18 +182,20 @@ def _get_all_feature_states_for_user_response(self, identity, trait_models=None) :return: Response containing lists of both serialized flags and traits """ all_feature_states = identity.get_all_feature_states() - serialized_flags = FeatureStateSerializerFull( - all_feature_states, many=True - ) + serialized_flags = FeatureStateSerializerFull(all_feature_states, many=True) serialized_traits = TraitSerializerBasic( identity.identity_traits.all(), many=True ) # If we have an amplitude configured, send the flags viewed by the user to their API - if hasattr(identity.environment, 'amplitude_config') and identity.environment.amplitude_config.api_key: + if ( + hasattr(identity.environment, "amplitude_config") + and identity.environment.amplitude_config.api_key + ): amplitude = AmplitudeWrapper(identity.environment.amplitude_config.api_key) - user_data = amplitude.generate_user_data(user_id=identity.identifier, - feature_states=all_feature_states) + user_data = amplitude.generate_user_data( + user_id=identity.identifier, feature_states=all_feature_states + ) amplitude.identify_user_async(user_data=user_data) response = {"flags": serialized_flags.data, "traits": serialized_traits.data} diff --git a/src/environments/models.py b/src/environments/models.py index 6ff8071aa8ef..29765d813f1f 100644 --- a/src/environments/models.py +++ b/src/environments/models.py @@ -26,7 +26,7 @@ @python_2_unicode_compatible class Environment(models.Model): name = models.CharField(max_length=2000) - created_date = models.DateTimeField('DateCreated', auto_now_add=True) + created_date = models.DateTimeField("DateCreated", auto_now_add=True) project = models.ForeignKey( Project, related_name="environments", @@ -36,14 +36,14 @@ class Environment(models.Model): "default Feature States will be created for the new selected projects Features for " "this Environment." ), - on_delete=models.CASCADE + on_delete=models.CASCADE, ) api_key = models.CharField(default=create_hash, unique=True, max_length=100) - webhooks_enabled = models.BooleanField(default=False, help_text='DEPRECATED FIELD.') - webhook_url = models.URLField(null=True, blank=True, help_text='DEPRECATED FIELD.') + webhooks_enabled = models.BooleanField(default=False, help_text="DEPRECATED FIELD.") + webhook_url = models.URLField(null=True, blank=True, help_text="DEPRECATED FIELD.") class Meta: - ordering = ['id'] + ordering = ["id"] def save(self, *args, **kwargs): """ @@ -54,7 +54,9 @@ def save(self, *args, **kwargs): old_environment = Environment.objects.get(pk=self.pk) if old_environment.project != self.project: FeatureState.objects.filter( - feature__in=old_environment.project.features.values_list('pk', flat=True), + feature__in=old_environment.project.features.values_list( + "pk", flat=True + ), environment=self, ).all().delete() requires_feature_state_creation = True @@ -69,7 +71,7 @@ def save(self, *args, **kwargs): feature=feature, environment=self, identity=None, - enabled=feature.default_enabled + enabled=feature.default_enabled, ) def __str__(self): @@ -78,19 +80,26 @@ def __str__(self): @staticmethod def get_environment_from_request(request): try: - environment_key = request.META['HTTP_X_ENVIRONMENT_KEY'] + 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) + return Environment.objects.select_related( + "project", "project__organisation" + ).get(api_key=environment_key) @classmethod def get_from_cache(cls, api_key): environment = environment_cache.get(api_key) if not environment: - select_related_args = ('project', 'project__organisation', 'amplitude_config') - environment = Environment.objects.select_related(*select_related_args).get(api_key=api_key) + select_related_args = ( + "project", + "project__organisation", + "amplitude_config", + ) + environment = Environment.objects.select_related(*select_related_args).get( + api_key=api_key + ) # TODO: replace the hard coded cache timeout with an environment variable # until we merge in the pulumi stuff, however, we'll have too many conflicts environment_cache.set(environment.api_key, environment, timeout=60) @@ -98,7 +107,9 @@ def get_from_cache(cls, api_key): class Webhook(models.Model): - environment = models.ForeignKey(Environment, on_delete=models.CASCADE, related_name='webhooks') + environment = models.ForeignKey( + Environment, on_delete=models.CASCADE, related_name="webhooks" + ) url = models.URLField() enabled = models.BooleanField(default=True) created_at = models.DateTimeField(auto_now_add=True) diff --git a/src/environments/sdk/serializers.py b/src/environments/sdk/serializers.py index a08d877eb322..0062e33fbe8d 100644 --- a/src/environments/sdk/serializers.py +++ b/src/environments/sdk/serializers.py @@ -1,11 +1,13 @@ from rest_framework import serializers -from environments.identities.traits.fields import TraitValueField from environments.identities.models import Identity -from environments.identities.serializers import IdentifierOnlyIdentitySerializer -from environments.models import INTEGER, FLOAT, BOOLEAN, STRING +from environments.identities.serializers import ( + IdentifierOnlyIdentitySerializer, +) +from environments.identities.traits.fields import TraitValueField from environments.identities.traits.models import Trait from environments.identities.traits.serializers import TraitSerializerBasic +from environments.models import BOOLEAN, FLOAT, INTEGER, STRING from features.serializers import FeatureStateSerializerFull from segments.serializers import SegmentSerializerBasic @@ -17,26 +19,32 @@ class SDKCreateUpdateTraitSerializer(serializers.ModelSerializer): class Meta: model = Trait - fields = ('identity', 'trait_value', 'trait_key') + fields = ("identity", "trait_value", "trait_key") def create(self, validated_data): - identity = self._get_identity(validated_data['identity']['identifier']) + identity = self._get_identity(validated_data["identity"]["identifier"]) - trait_key = validated_data['trait_key'] - trait_value = validated_data['trait_value']['value'] - trait_value_type = validated_data['trait_value']['type'] + trait_key = validated_data["trait_key"] + trait_value = validated_data["trait_value"]["value"] + trait_value_type = validated_data["trait_value"]["type"] value_key = Trait.get_trait_value_key_name(trait_value_type) defaults = { value_key: trait_value, - 'value_type': trait_value_type if trait_value_type in [FLOAT, INTEGER, BOOLEAN] else STRING + "value_type": trait_value_type + if trait_value_type in [FLOAT, INTEGER, BOOLEAN] + else STRING, } - return Trait.objects.update_or_create(identity=identity, trait_key=trait_key, defaults=defaults)[0] + return Trait.objects.update_or_create( + identity=identity, trait_key=trait_key, defaults=defaults + )[0] def _get_identity(self, identifier): - return Identity.objects.get_or_create(identifier=identifier, environment=self.context['environment'])[0] + return Identity.objects.get_or_create( + identifier=identifier, environment=self.context["environment"] + )[0] class SDKBulkCreateUpdateTraitSerializer(SDKCreateUpdateTraitSerializer): @@ -65,9 +73,9 @@ def create(self, validated_data): Create the identity with the associated traits (optionally store traits if flag set on org) """ - environment = self.context['environment'] + environment = self.context["environment"] identity, created = Identity.objects.get_or_create( - identifier=validated_data['identifier'], environment=environment + identifier=validated_data["identifier"], environment=environment ) if not created and environment.project.organisation.persist_trait_data: @@ -77,23 +85,23 @@ def create(self, validated_data): # generate traits for the identity and store them if configured to do so trait_models = identity.generate_traits( - validated_data.get('traits', []), - persist=environment.project.organisation.persist_trait_data + validated_data.get("traits", []), + persist=environment.project.organisation.persist_trait_data, ) return { "identity": identity, "traits": trait_models, - "flags": identity.get_all_feature_states(traits=trait_models) + "flags": identity.get_all_feature_states(traits=trait_models), } def update(self, instance, validated_data): """ partially update any traits and return the full list of traits and flags """ - trait_data_items = validated_data.get('traits', []) + trait_data_items = validated_data.get("traits", []) updated_traits = instance.update_traits(trait_data_items) return { "identity": instance, "traits": updated_traits, - "flags": instance.get_all_feature_states(traits=updated_traits) - } \ No newline at end of file + "flags": instance.get_all_feature_states(traits=updated_traits), + } diff --git a/src/environments/serializers.py b/src/environments/serializers.py index f7e06972512a..4ac36f2b09d7 100644 --- a/src/environments/serializers.py +++ b/src/environments/serializers.py @@ -1,6 +1,11 @@ from rest_framework import serializers -from audit.models import ENVIRONMENT_CREATED_MESSAGE, ENVIRONMENT_UPDATED_MESSAGE, RelatedObjectType, AuditLog +from audit.models import ( + ENVIRONMENT_CREATED_MESSAGE, + ENVIRONMENT_UPDATED_MESSAGE, + AuditLog, + RelatedObjectType, +) from environments.models import Environment, Webhook from features.serializers import FeatureStateSerializerFull from projects.serializers import ProjectSerializer @@ -12,14 +17,14 @@ class EnvironmentSerializerFull(serializers.ModelSerializer): class Meta: model = Environment - fields = ('id', 'name', 'feature_states', 'project', 'api_key') + fields = ("id", "name", "feature_states", "project", "api_key") class EnvironmentSerializerLight(serializers.ModelSerializer): class Meta: model = Environment - fields = ('id', 'name', 'api_key', 'project') - read_only_fields = ('api_key',) + fields = ("id", "name", "api_key", "project") + read_only_fields = ("api_key",) def create(self, validated_data): instance = super(EnvironmentSerializerLight, self).create(validated_data) @@ -27,25 +32,29 @@ def create(self, validated_data): return instance def update(self, instance, validated_data): - updated_instance = super(EnvironmentSerializerLight, self).update(instance, validated_data) + updated_instance = super(EnvironmentSerializerLight, self).update( + instance, validated_data + ) self._create_audit_log(instance, False) return updated_instance def _create_audit_log(self, instance, created): - message = (ENVIRONMENT_CREATED_MESSAGE if created else ENVIRONMENT_UPDATED_MESSAGE) % instance.name - request = self.context.get('request') - AuditLog.objects.create(author=request.user if request else None, - related_object_id=instance.id, - related_object_type=RelatedObjectType.ENVIRONMENT.name, - environment=instance, - project=instance.project, - log=message) + message = ( + ENVIRONMENT_CREATED_MESSAGE if created else ENVIRONMENT_UPDATED_MESSAGE + ) % instance.name + request = self.context.get("request") + AuditLog.objects.create( + author=request.user if request else None, + related_object_id=instance.id, + related_object_type=RelatedObjectType.ENVIRONMENT.name, + environment=instance, + project=instance.project, + log=message, + ) class WebhookSerializer(serializers.ModelSerializer): class Meta: model = Webhook - fields = ('id', 'url', 'enabled', 'created_at', 'updated_at') - read_only_fields = ('id', 'created_at', 'updated_at') - - + fields = ("id", "url", "enabled", "created_at", "updated_at") + read_only_fields = ("id", "created_at", "updated_at") diff --git a/src/environments/tests/test_authentication.py b/src/environments/tests/test_authentication.py index 25fd28210822..b0897f6f8c02 100644 --- a/src/environments/tests/test_authentication.py +++ b/src/environments/tests/test_authentication.py @@ -3,8 +3,8 @@ import pytest from axes.models import AccessAttempt -from django.contrib.auth import authenticate from django.conf import settings +from django.contrib.auth import authenticate from django.http import HttpRequest from rest_framework.exceptions import AuthenticationFailed @@ -17,9 +17,13 @@ @pytest.mark.django_db class EnvironmentKeyAuthenticationTestCase(TestCase): def setUp(self) -> None: - self.organisation = Organisation.objects.create(name='Test org') - self.project = Project.objects.create(name='Test project', organisation=self.organisation) - self.environment = Environment.objects.create(name='Test environment', project=self.project) + self.organisation = Organisation.objects.create(name="Test org") + self.project = Project.objects.create( + name="Test project", organisation=self.organisation + ) + self.environment = Environment.objects.create( + name="Test environment", project=self.project + ) self.authenticator = EnvironmentKeyAuthentication() @@ -34,7 +38,9 @@ def test_authentication_passes_if_valid_api_key_passed(self): # Then - authentication passes pass - def test_authenticate_raises_authentication_failed_if_request_missing_environment_key(self): + def test_authenticate_raises_authentication_failed_if_request_missing_environment_key( + self, + ): # Given request = MagicMock() @@ -42,16 +48,20 @@ def test_authenticate_raises_authentication_failed_if_request_missing_environmen with pytest.raises(AuthenticationFailed): self.authenticator.authenticate(request) - def test_authenticate_raises_authentication_failed_if_request_environment_key_not_found(self): + def test_authenticate_raises_authentication_failed_if_request_environment_key_not_found( + self, + ): # Given request = MagicMock() - request.META.get.return_value = 'some-invalid-key' + request.META.get.return_value = "some-invalid-key" # When / Then with pytest.raises(AuthenticationFailed): self.authenticator.authenticate(request) - def test_authenticate_raises_authentication_failed_if_organisation_set_to_stop_serving_flags(self): + def test_authenticate_raises_authentication_failed_if_organisation_set_to_stop_serving_flags( + self, + ): # Given self.organisation.stop_serving_flags = True self.organisation.save() @@ -74,6 +84,8 @@ def test_brute_force_attempts(self): for _ in range(login_attempts_to_make): request = HttpRequest() - authenticate(request, username=invalid_user_name, password="invalid_password") + authenticate( + request, username=invalid_user_name, password="invalid_password" + ) assert AccessAttempt.objects.filter(username=invalid_user_name).count() == 1 diff --git a/src/environments/tests/test_models.py b/src/environments/tests/test_models.py index 92fe6cc373d4..a99cd430349c 100644 --- a/src/environments/tests/test_models.py +++ b/src/environments/tests/test_models.py @@ -12,7 +12,9 @@ class EnvironmentSaveTestCase(TestCase): def setUp(self): self.organisation = Organisation.objects.create(name="Test Org") - self.project = Project.objects.create(name="Test Project", organisation=self.organisation) + self.project = Project.objects.create( + name="Test Project", organisation=self.organisation + ) self.feature = Feature.objects.create(name="Test Feature", project=self.project) # The environment is initialised in a non-saved state as we want to test the save # functionality. @@ -26,7 +28,7 @@ def test_environment_should_be_created_with_feature_states(self): # Then feature_states = FeatureState.objects.filter(environment=self.environment) - assert hasattr(self.environment, 'api_key') + assert hasattr(self.environment, "api_key") assert feature_states.count() == 1 def test_on_creation_save_feature_states_get_created(self): @@ -62,7 +64,9 @@ def test_on_update_save_feature_gets_updated_with_the_correct_default(self): def test_on_update_save_feature_states_dont_get_updated_if_identity_present(self): self.environment.save() - identity = Identity.objects.create(identifier="test-identity", environment=self.environment) + identity = Identity.objects.create( + identifier="test-identity", environment=self.environment + ) fs = FeatureState.objects.get() fs.id = None @@ -75,4 +79,6 @@ def test_on_update_save_feature_states_dont_get_updated_if_identity_present(self self.environment.save() fs.refresh_from_db() - self.assertNotEqual(fs.enabled, FeatureState.objects.exclude(id=fs.id).get().enabled) + self.assertNotEqual( + fs.enabled, FeatureState.objects.exclude(id=fs.id).get().enabled + ) diff --git a/src/environments/tests/test_views.py b/src/environments/tests/test_views.py index ab5a61ee721e..e3f11f9a1c4a 100644 --- a/src/environments/tests/test_views.py +++ b/src/environments/tests/test_views.py @@ -7,13 +7,17 @@ from rest_framework.test import APIClient from audit.models import AuditLog, RelatedObjectType -from environments.models import Environment, STRING, Webhook -from environments.identities.traits.models import Trait from environments.identities.models import Identity +from environments.identities.traits.models import Trait +from environments.models import STRING, Environment, Webhook from environments.permissions.models import UserEnvironmentPermission from features.models import Feature, FeatureState from organisations.models import Organisation, OrganisationRole -from projects.models import Project, UserProjectPermission, ProjectPermissionModel +from projects.models import ( + Project, + ProjectPermissionModel, + UserProjectPermission, +) from users.models import FFAdminUser from util.tests import Helper @@ -28,16 +32,26 @@ def setUp(self): self.user = Helper.create_ffadminuser() self.client.force_authenticate(user=self.user) - create_environment_permission = ProjectPermissionModel.objects.get(key="CREATE_ENVIRONMENT") + create_environment_permission = ProjectPermissionModel.objects.get( + key="CREATE_ENVIRONMENT" + ) read_project_permission = ProjectPermissionModel.objects.get(key="VIEW_PROJECT") - self.organisation = Organisation.objects.create(name='ssg') - self.user.add_organisation(self.organisation, OrganisationRole.ADMIN) # admin to bypass perms + self.organisation = Organisation.objects.create(name="ssg") + self.user.add_organisation( + self.organisation, OrganisationRole.ADMIN + ) # admin to bypass perms - self.project = Project.objects.create(name='Test project', organisation=self.organisation) + self.project = Project.objects.create( + name="Test project", organisation=self.organisation + ) - user_project_permission = UserProjectPermission.objects.create(user=self.user, project=self.project) - user_project_permission.permissions.add(create_environment_permission, read_project_permission) + user_project_permission = UserProjectPermission.objects.create( + user=self.user, project=self.project + ) + user_project_permission.permissions.add( + create_environment_permission, read_project_permission + ) def tearDown(self) -> None: Environment.objects.all().delete() @@ -45,11 +59,8 @@ def tearDown(self) -> None: def test_should_create_environments(self): # Given - url = reverse('api-v1:environments:environment-list') - data = { - 'name': 'Test environment', - 'project': self.project.id - } + url = reverse("api-v1:environments:environment-list") + data = {"name": "Test environment", "project": self.project.id} # When response = self.client.post(url, data=data) @@ -58,36 +69,49 @@ def test_should_create_environments(self): assert response.status_code == status.HTTP_201_CREATED # and user is admin - assert UserEnvironmentPermission.objects.filter(user=self.user, admin=True, - environment__id=response.json()['id']).exists() + assert UserEnvironmentPermission.objects.filter( + user=self.user, admin=True, environment__id=response.json()["id"] + ).exists() def test_should_return_identities_for_an_environment(self): # Given - identifier_one = 'user1' - identifier_two = 'user2' - environment = Environment.objects.create(name='environment1', project=self.project) + identifier_one = "user1" + identifier_two = "user2" + environment = Environment.objects.create( + name="environment1", project=self.project + ) Identity.objects.create(identifier=identifier_one, environment=environment) Identity.objects.create(identifier=identifier_two, environment=environment) - url = reverse('api-v1:environments:environment-identities-list', args=[environment.api_key]) + url = reverse( + "api-v1:environments:environment-identities-list", + args=[environment.api_key], + ) # When response = self.client.get(url) # Then - assert response.data['results'][0]['identifier'] == identifier_one - assert response.data['results'][1]['identifier'] == identifier_two + assert response.data["results"][0]["identifier"] == identifier_one + assert response.data["results"][1]["identifier"] == identifier_two def test_should_update_value_of_feature_state(self): # Given feature = Feature.objects.create(name="feature", project=self.project) environment = Environment.objects.create(name="test env", project=self.project) - feature_state = FeatureState.objects.get(feature=feature, environment=environment) - url = reverse('api-v1:environments:environment-featurestates-detail', - args=[environment.api_key, feature_state.id]) + feature_state = FeatureState.objects.get( + feature=feature, environment=environment + ) + url = reverse( + "api-v1:environments:environment-featurestates-detail", + args=[environment.api_key, feature_state.id], + ) # When - response = self.client.put(url, data=self.fs_put_template % (feature_state.id, True, "This is a value"), - content_type='application/json') + response = self.client.put( + url, + data=self.fs_put_template % (feature_state.id, True, "This is a value"), + content_type="application/json", + ) # Then feature_state.refresh_from_db() @@ -98,69 +122,106 @@ def test_should_update_value_of_feature_state(self): def test_audit_log_entry_created_when_new_environment_created(self): # Given - url = reverse('api-v1:environments:environment-list') - data = { - 'project': self.project.id, - 'name': 'Test Environment' - } + url = reverse("api-v1:environments:environment-list") + data = {"project": self.project.id, "name": "Test Environment"} # When self.client.post(url, data=data) # Then - assert AuditLog.objects.filter(related_object_type=RelatedObjectType.ENVIRONMENT.name).count() == 1 + assert ( + AuditLog.objects.filter( + related_object_type=RelatedObjectType.ENVIRONMENT.name + ).count() + == 1 + ) def test_audit_log_entry_created_when_environment_updated(self): # Given - environment = Environment.objects.create(name='Test environment', project=self.project) - url = reverse('api-v1:environments:environment-detail', args=[environment.api_key]) - data = { - 'project': self.project.id, - 'name': 'New name' - } + environment = Environment.objects.create( + name="Test environment", project=self.project + ) + url = reverse( + "api-v1:environments:environment-detail", args=[environment.api_key] + ) + data = {"project": self.project.id, "name": "New name"} # When self.client.put(url, data=data) # Then - assert AuditLog.objects.filter(related_object_type=RelatedObjectType.ENVIRONMENT.name).count() == 1 + assert ( + AuditLog.objects.filter( + related_object_type=RelatedObjectType.ENVIRONMENT.name + ).count() + == 1 + ) def test_audit_log_created_when_feature_state_updated(self): # Given feature = Feature.objects.create(name="feature", project=self.project) environment = Environment.objects.create(name="test env", project=self.project) - feature_state = FeatureState.objects.get(feature=feature, environment=environment) - url = reverse('api-v1:environments:environment-featurestates-detail', - args=[environment.api_key, feature_state.id]) - data = { - 'id': feature.id, - 'enabled': True - } + feature_state = FeatureState.objects.get( + feature=feature, environment=environment + ) + url = reverse( + "api-v1:environments:environment-featurestates-detail", + args=[environment.api_key, feature_state.id], + ) + data = {"id": feature.id, "enabled": True} # When self.client.put(url, data=data) # Then - assert AuditLog.objects.filter(related_object_type=RelatedObjectType.FEATURE_STATE.name).count() == 1 + assert ( + AuditLog.objects.filter( + related_object_type=RelatedObjectType.FEATURE_STATE.name + ).count() + == 1 + ) # and assert AuditLog.objects.first().author def test_get_all_trait_keys_for_environment_only_returns_distinct_keys(self): # Given - trait_key_one = 'trait-key-one' - trait_key_two = 'trait-key-two' - - environment = Environment.objects.create(project=self.project, name='Test Environment') - - identity_one = Identity.objects.create(environment=environment, identifier='identity-one') - identity_two = Identity.objects.create(environment=environment, identifier='identity-two') - - Trait.objects.create(identity=identity_one, trait_key=trait_key_one, string_value='blah', value_type=STRING) - Trait.objects.create(identity=identity_one, trait_key=trait_key_two, string_value='blah', value_type=STRING) - Trait.objects.create(identity=identity_two, trait_key=trait_key_one, string_value='blah', value_type=STRING) - - url = reverse('api-v1:environments:environment-trait-keys', args=[environment.api_key]) + trait_key_one = "trait-key-one" + trait_key_two = "trait-key-two" + + environment = Environment.objects.create( + project=self.project, name="Test Environment" + ) + + identity_one = Identity.objects.create( + environment=environment, identifier="identity-one" + ) + identity_two = Identity.objects.create( + environment=environment, identifier="identity-two" + ) + + Trait.objects.create( + identity=identity_one, + trait_key=trait_key_one, + string_value="blah", + value_type=STRING, + ) + Trait.objects.create( + identity=identity_one, + trait_key=trait_key_two, + string_value="blah", + value_type=STRING, + ) + Trait.objects.create( + identity=identity_two, + trait_key=trait_key_one, + string_value="blah", + value_type=STRING, + ) + + url = reverse( + "api-v1:environments:environment-trait-keys", args=[environment.api_key] + ) # When res = self.client.get(url) @@ -169,77 +230,126 @@ def test_get_all_trait_keys_for_environment_only_returns_distinct_keys(self): assert res.status_code == status.HTTP_200_OK # and - only distinct keys are returned - assert len(res.json().get('keys')) == 2 + assert len(res.json().get("keys")) == 2 def test_delete_trait_keys_deletes_trait_for_all_users_in_that_environment(self): # Given - environment_one = Environment.objects.create(project=self.project, name='Test Environment 1') - environment_two = Environment.objects.create(project=self.project, name='Test Environment 2') - - identity_one_environment_one = Identity.objects.create(environment=environment_one, - identifier='identity-one-env-one') - identity_one_environment_two = Identity.objects.create(environment=environment_two, - identifier='identity-one-env-two') - - trait_key = 'trait-key' - Trait.objects.create(identity=identity_one_environment_one, trait_key=trait_key, string_value='blah', - value_type=STRING) - Trait.objects.create(identity=identity_one_environment_two, trait_key=trait_key, string_value='blah', - value_type=STRING) - - url = reverse('api-v1:environments:environment-delete-traits', args=[environment_one.api_key]) + environment_one = Environment.objects.create( + project=self.project, name="Test Environment 1" + ) + environment_two = Environment.objects.create( + project=self.project, name="Test Environment 2" + ) + + identity_one_environment_one = Identity.objects.create( + environment=environment_one, identifier="identity-one-env-one" + ) + identity_one_environment_two = Identity.objects.create( + environment=environment_two, identifier="identity-one-env-two" + ) + + trait_key = "trait-key" + Trait.objects.create( + identity=identity_one_environment_one, + trait_key=trait_key, + string_value="blah", + value_type=STRING, + ) + Trait.objects.create( + identity=identity_one_environment_two, + trait_key=trait_key, + string_value="blah", + value_type=STRING, + ) + + url = reverse( + "api-v1:environments:environment-delete-traits", + args=[environment_one.api_key], + ) # When - self.client.post(url, data={'key': trait_key}) + self.client.post(url, data={"key": trait_key}) # Then - assert not Trait.objects.filter(identity=identity_one_environment_one, trait_key=trait_key).exists() + assert not Trait.objects.filter( + identity=identity_one_environment_one, trait_key=trait_key + ).exists() # and - assert Trait.objects.filter(identity=identity_one_environment_two, trait_key=trait_key).exists() + assert Trait.objects.filter( + identity=identity_one_environment_two, trait_key=trait_key + ).exists() def test_delete_trait_keys_deletes_traits_matching_provided_key_only(self): # Given - environment = Environment.objects.create(project=self.project, name='Test Environment') - - identity = Identity.objects.create(identifier='test-identity', environment=environment) - - trait_to_delete = 'trait-key-to-delete' - Trait.objects.create(identity=identity, trait_key=trait_to_delete, value_type=STRING, string_value='blah') - - trait_to_persist = 'trait-key-to-persist' - Trait.objects.create(identity=identity, trait_key=trait_to_persist, value_type=STRING, string_value='blah') - - url = reverse('api-v1:environments:environment-delete-traits', args=[environment.api_key]) + environment = Environment.objects.create( + project=self.project, name="Test Environment" + ) + + identity = Identity.objects.create( + identifier="test-identity", environment=environment + ) + + trait_to_delete = "trait-key-to-delete" + Trait.objects.create( + identity=identity, + trait_key=trait_to_delete, + value_type=STRING, + string_value="blah", + ) + + trait_to_persist = "trait-key-to-persist" + Trait.objects.create( + identity=identity, + trait_key=trait_to_persist, + value_type=STRING, + string_value="blah", + ) + + url = reverse( + "api-v1:environments:environment-delete-traits", args=[environment.api_key] + ) # When - self.client.post(url, data={'key': trait_to_delete}) + self.client.post(url, data={"key": trait_to_delete}) # Then - assert not Trait.objects.filter(identity=identity, trait_key=trait_to_delete).exists() + assert not Trait.objects.filter( + identity=identity, trait_key=trait_to_delete + ).exists() # and - assert Trait.objects.filter(identity=identity, trait_key=trait_to_persist).exists() + assert Trait.objects.filter( + identity=identity, trait_key=trait_to_persist + ).exists() def test_user_can_list_environment_permission(self): # Given - url = reverse('api-v1:environments:environment-permissions') + url = reverse("api-v1:environments:environment-permissions") # When response = self.client.get(url) # Then assert response.status_code == status.HTTP_200_OK - assert len(response.json()) == 1 # hard code how many permissions we expect there to be + assert ( + len(response.json()) == 1 + ) # hard code how many permissions we expect there to be def test_environment_user_can_get_their_permissions(self): # Given - user = FFAdminUser.objects.create(email='new-test@test.com') + user = FFAdminUser.objects.create(email="new-test@test.com") user.add_organisation(self.organisation) - environment = Environment.objects.create(name='Test environment', project=self.project) - user_permission = UserEnvironmentPermission.objects.create(user=user, environment=environment) - user_permission.add_permission('VIEW_ENVIRONMENT') - url = reverse('api-v1:environments:environment-my-permissions', args=[environment.api_key]) + environment = Environment.objects.create( + name="Test environment", project=self.project + ) + user_permission = UserEnvironmentPermission.objects.create( + user=user, environment=environment + ) + user_permission.add_permission("VIEW_ENVIRONMENT") + url = reverse( + "api-v1:environments:environment-my-permissions", args=[environment.api_key] + ) # When self.client.force_authenticate(user) @@ -247,8 +357,8 @@ def test_environment_user_can_get_their_permissions(self): # Then assert response.status_code == status.HTTP_200_OK - assert not response.json()['admin'] - assert 'VIEW_ENVIRONMENT' in response.json()['permissions'] + assert not response.json()["admin"] + assert "VIEW_ENVIRONMENT" in response.json()["permissions"] @pytest.mark.django_db @@ -258,21 +368,23 @@ def setUp(self) -> None: user = Helper.create_ffadminuser() self.client.force_authenticate(user=user) - organisation = Organisation.objects.create(name='Test organisation') + organisation = Organisation.objects.create(name="Test organisation") user.add_organisation(organisation, OrganisationRole.ADMIN) - project = Project.objects.create(name='Test project', organisation=organisation) - self.environment = Environment.objects.create(name='Test environment', project=project) + project = Project.objects.create(name="Test project", organisation=organisation) + self.environment = Environment.objects.create( + name="Test environment", project=project + ) - self.valid_webhook_url = 'http://my.webhook.com/webhooks' + self.valid_webhook_url = "http://my.webhook.com/webhooks" def test_can_create_webhook_for_an_environment(self): # Given - url = reverse('api-v1:environments:environment-webhooks-list', args=[self.environment.api_key]) - data = { - 'url': self.valid_webhook_url, - 'enabled': True - } + url = reverse( + "api-v1:environments:environment-webhooks-list", + args=[self.environment.api_key], + ) + data = {"url": self.valid_webhook_url, "enabled": True} # When res = self.client.post(url, data) @@ -285,27 +397,36 @@ def test_can_create_webhook_for_an_environment(self): def test_can_update_webhook_for_an_environment(self): # Given - webhook = Webhook.objects.create(url=self.valid_webhook_url, environment=self.environment) - url = reverse('api-v1:environments:environment-webhooks-detail', args=[self.environment.api_key, webhook.id]) - data = { - 'url': 'http://my.new.url.com/wehbooks', - 'enabled': False - } + webhook = Webhook.objects.create( + url=self.valid_webhook_url, environment=self.environment + ) + url = reverse( + "api-v1:environments:environment-webhooks-detail", + args=[self.environment.api_key, webhook.id], + ) + data = {"url": "http://my.new.url.com/wehbooks", "enabled": False} # When - res = self.client.put(url, data=json.dumps(data), content_type='application/json') + res = self.client.put( + url, data=json.dumps(data), content_type="application/json" + ) # Then assert res.status_code == status.HTTP_200_OK # and webhook.refresh_from_db() - assert webhook.url == data['url'] and not webhook.enabled + assert webhook.url == data["url"] and not webhook.enabled def test_can_delete_webhook_for_an_environment(self): # Given - webhook = Webhook.objects.create(url=self.valid_webhook_url, environment=self.environment) - url = reverse('api-v1:environments:environment-webhooks-detail', args=[self.environment.api_key, webhook.id]) + webhook = Webhook.objects.create( + url=self.valid_webhook_url, environment=self.environment + ) + url = reverse( + "api-v1:environments:environment-webhooks-detail", + args=[self.environment.api_key, webhook.id], + ) # When res = self.client.delete(url) @@ -318,8 +439,13 @@ def test_can_delete_webhook_for_an_environment(self): def test_can_list_webhooks_for_an_environment(self): # Given - webhook = Webhook.objects.create(url=self.valid_webhook_url, environment=self.environment) - url = reverse('api-v1:environments:environment-webhooks-list', args=[self.environment.api_key]) + webhook = Webhook.objects.create( + url=self.valid_webhook_url, environment=self.environment + ) + url = reverse( + "api-v1:environments:environment-webhooks-list", + args=[self.environment.api_key], + ) # When res = self.client.get(url) @@ -328,15 +454,24 @@ def test_can_list_webhooks_for_an_environment(self): assert res.status_code == status.HTTP_200_OK # and - assert res.json()[0]['id'] == webhook.id + assert res.json()[0]["id"] == webhook.id def test_cannot_delete_webhooks_for_environment_user_does_not_belong_to(self): # Given - new_organisation = Organisation.objects.create(name='New organisation') - new_project = Project.objects.create(name='New project', organisation=new_organisation) - new_environment = Environment.objects.create(name='New Environment', project=new_project) - webhook = Webhook.objects.create(url=self.valid_webhook_url, environment=new_environment) - url = reverse('api-v1:environments:environment-webhooks-detail', args=[self.environment.api_key, webhook.id]) + new_organisation = Organisation.objects.create(name="New organisation") + new_project = Project.objects.create( + name="New project", organisation=new_organisation + ) + new_environment = Environment.objects.create( + name="New Environment", project=new_project + ) + webhook = Webhook.objects.create( + url=self.valid_webhook_url, environment=new_environment + ) + url = reverse( + "api-v1:environments:environment-webhooks-detail", + args=[self.environment.api_key, webhook.id], + ) # When res = self.client.delete(url) @@ -346,5 +481,3 @@ def test_cannot_delete_webhooks_for_environment_user_does_not_belong_to(self): # and assert Webhook.objects.filter(id=webhook.id).exists() - - diff --git a/src/environments/urls.py b/src/environments/urls.py index 6a914081dfb8..2014839eaca9 100644 --- a/src/environments/urls.py +++ b/src/environments/urls.py @@ -1,35 +1,58 @@ -from django.conf.urls import url, include +from django.conf.urls import include, url from rest_framework_nested import routers from features.views import FeatureStateViewSet from integrations.amplitude.views import AmplitudeConfigurationViewSet + from .identities.traits.views import TraitViewSet from .identities.views import IdentityViewSet -from .permissions.views import UserEnvironmentPermissionsViewSet, UserPermissionGroupEnvironmentPermissionsViewSet +from .permissions.views import ( + UserEnvironmentPermissionsViewSet, + UserPermissionGroupEnvironmentPermissionsViewSet, +) from .views import EnvironmentViewSet, WebhookViewSet router = routers.DefaultRouter() -router.register(r'', EnvironmentViewSet, basename="environment") +router.register(r"", EnvironmentViewSet, basename="environment") -environments_router = routers.NestedSimpleRouter(router, r'', lookup="environment") -environments_router.register(r'identities', IdentityViewSet, basename="environment-identities") -environments_router.register(r'webhooks', WebhookViewSet, basename='environment-webhooks') -environments_router.register(r'featurestates', FeatureStateViewSet, basename="environment-featurestates") -environments_router.register(r'user-permissions', UserEnvironmentPermissionsViewSet, - basename='environment-user-permissions') -environments_router.register(r'user-group-permissions', UserPermissionGroupEnvironmentPermissionsViewSet, - basename='environment-user-group-permissions') -environments_router.register(r'integrations/amplitude', AmplitudeConfigurationViewSet, - basename="integrations-amplitude") +environments_router = routers.NestedSimpleRouter(router, r"", lookup="environment") +environments_router.register( + r"identities", IdentityViewSet, basename="environment-identities" +) +environments_router.register( + r"webhooks", WebhookViewSet, basename="environment-webhooks" +) +environments_router.register( + r"featurestates", FeatureStateViewSet, basename="environment-featurestates" +) +environments_router.register( + r"user-permissions", + UserEnvironmentPermissionsViewSet, + basename="environment-user-permissions", +) +environments_router.register( + r"user-group-permissions", + UserPermissionGroupEnvironmentPermissionsViewSet, + basename="environment-user-group-permissions", +) +environments_router.register( + r"integrations/amplitude", + AmplitudeConfigurationViewSet, + basename="integrations-amplitude", +) -identity_router = routers.NestedSimpleRouter(environments_router, r'identities', lookup="identity") -identity_router.register(r'featurestates', FeatureStateViewSet, basename="identity-featurestates") -identity_router.register(r'traits', TraitViewSet, basename="identities-traits") +identity_router = routers.NestedSimpleRouter( + environments_router, r"identities", lookup="identity" +) +identity_router.register( + r"featurestates", FeatureStateViewSet, basename="identity-featurestates" +) +identity_router.register(r"traits", TraitViewSet, basename="identities-traits") app_name = "environments" urlpatterns = [ - url(r'^', include(router.urls)), - url(r'^', include(environments_router.urls)), - url(r'^', include(identity_router.urls)) + url(r"^", include(router.urls)), + url(r"^", include(environments_router.urls)), + url(r"^", include(identity_router.urls)), ] diff --git a/src/environments/views.py b/src/environments/views.py index cb2eed2de413..432a5b1710d2 100644 --- a/src/environments/views.py +++ b/src/environments/views.py @@ -4,50 +4,72 @@ from django.utils.decorators import method_decorator from drf_yasg2 import openapi from drf_yasg2.utils import swagger_auto_schema -from rest_framework import viewsets, status, mixins +from rest_framework import mixins, status, viewsets from rest_framework.decorators import action from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from environments.permissions.permissions import EnvironmentPermissions, \ - NestedEnvironmentPermissions -from permissions.serializers import PermissionModelSerializer, MyUserObjectPermissionsSerializer +from environments.permissions.permissions import ( + EnvironmentPermissions, + NestedEnvironmentPermissions, +) +from permissions.serializers import ( + MyUserObjectPermissionsSerializer, + PermissionModelSerializer, +) from util.logging import get_logger -from .models import Environment, Webhook + from .identities.traits.models import Trait -from .permissions.models import EnvironmentPermissionModel, UserEnvironmentPermission, \ - UserPermissionGroupEnvironmentPermission +from .identities.traits.serializers import ( + DeleteAllTraitKeysSerializer, + TraitKeysSerializer, +) +from .models import Environment, Webhook +from .permissions.models import ( + EnvironmentPermissionModel, + UserEnvironmentPermission, + UserPermissionGroupEnvironmentPermission, +) from .serializers import EnvironmentSerializerLight, WebhookSerializer -from .identities.traits.serializers import TraitKeysSerializer, DeleteAllTraitKeysSerializer logger = get_logger(__name__) -@method_decorator(name='list', decorator=swagger_auto_schema(manual_parameters=[ - openapi.Parameter('project', openapi.IN_QUERY, - 'ID of the project to filter by.', required=False, type=openapi.TYPE_INTEGER) -])) +@method_decorator( + name="list", + decorator=swagger_auto_schema( + manual_parameters=[ + openapi.Parameter( + "project", + openapi.IN_QUERY, + "ID of the project to filter by.", + required=False, + type=openapi.TYPE_INTEGER, + ) + ] + ), +) class EnvironmentViewSet(viewsets.ModelViewSet): - lookup_field = 'api_key' + lookup_field = "api_key" permission_classes = [IsAuthenticated, EnvironmentPermissions] def get_serializer_class(self): - if self.action == 'trait_keys': + if self.action == "trait_keys": return TraitKeysSerializer - if self.action == 'delete_traits': + if self.action == "delete_traits": return DeleteAllTraitKeysSerializer return EnvironmentSerializerLight def get_serializer_context(self): context = super(EnvironmentViewSet, self).get_serializer_context() - if self.kwargs.get('api_key'): - context['environment'] = self.get_object() + if self.kwargs.get("api_key"): + context["environment"] = self.get_object() return context def get_queryset(self): - queryset = self.request.user.get_permitted_environments(['VIEW_ENVIRONMENT']) + queryset = self.request.user.get_permitted_environments(["VIEW_ENVIRONMENT"]) - project_id = self.request.query_params.get('project') + project_id = self.request.query_params.get("project") if project_id: queryset = queryset.filter(project__id=project_id) @@ -55,61 +77,97 @@ def get_queryset(self): def perform_create(self, serializer): environment = serializer.save() - UserEnvironmentPermission.objects.create(user=self.request.user, environment=environment, admin=True) + UserEnvironmentPermission.objects.create( + user=self.request.user, environment=environment, admin=True + ) - @action(detail=True, methods=['GET'], url_path='trait-keys') + @action(detail=True, methods=["GET"], url_path="trait-keys") def trait_keys(self, request, *args, **kwargs): - keys = [trait_key for trait_key in Trait.objects.filter( - identity__environment=self.get_object()).order_by().values_list('trait_key', flat=True).distinct()] - - data = { - 'keys': keys - } + keys = [ + trait_key + for trait_key in Trait.objects.filter( + identity__environment=self.get_object() + ) + .order_by() + .values_list("trait_key", flat=True) + .distinct() + ] + + data = {"keys": keys} serializer = self.get_serializer(data=data) if serializer.is_valid(): return Response(serializer.data, status=status.HTTP_200_OK) else: - return Response({'detail': 'Couldn\'t get trait keys'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + return Response( + {"detail": "Couldn't get trait keys"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) - @action(detail=True, methods=['POST'], url_path='delete-traits') + @action(detail=True, methods=["POST"], url_path="delete-traits") def delete_traits(self, request, *args, **kwargs): serializer = self.get_serializer(data=request.data) if serializer.is_valid(): serializer.delete() return Response(status=status.HTTP_200_OK) else: - return Response({'detail': 'Couldn\'t delete trait keys.'}, status=status.HTTP_400_BAD_REQUEST) + return Response( + {"detail": "Couldn't delete trait keys."}, + status=status.HTTP_400_BAD_REQUEST, + ) @swagger_auto_schema(responses={200: PermissionModelSerializer}) @action(detail=False, methods=["GET"]) def permissions(self, *args, **kwargs): - return Response(PermissionModelSerializer(instance=EnvironmentPermissionModel.objects.all(), many=True).data) + return Response( + PermissionModelSerializer( + instance=EnvironmentPermissionModel.objects.all(), many=True + ).data + ) @swagger_auto_schema(responses={200: MyUserObjectPermissionsSerializer}) - @action(detail=True, methods=["GET"], url_path="my-permissions", url_name="my-permissions") + @action( + detail=True, + methods=["GET"], + url_path="my-permissions", + url_name="my-permissions", + ) def user_permissions(self, request, *args, **kwargs): # TODO: tidy this mess up environment = self.get_object() - group_permissions = UserPermissionGroupEnvironmentPermission.objects.filter(group__users=request.user, - environment=environment) - user_permissions = UserEnvironmentPermission.objects.filter(user=request.user, environment=environment) + group_permissions = UserPermissionGroupEnvironmentPermission.objects.filter( + group__users=request.user, environment=environment + ) + user_permissions = UserEnvironmentPermission.objects.filter( + user=request.user, environment=environment + ) permissions = set() for group_permission in group_permissions: permissions = permissions.union( - {permission.key for permission in group_permission.permissions.all() if permission.key}) + { + permission.key + for permission in group_permission.permissions.all() + if permission.key + } + ) for user_permission in user_permissions: permissions = permissions.union( - {permission.key for permission in user_permission.permissions.all() if permission.key}) + { + permission.key + for permission in user_permission.permissions.all() + if permission.key + } + ) is_project_admin = request.user.is_project_admin(environment.project) data = { - 'admin': group_permissions.filter(admin=True).exists() or user_permissions.filter( - admin=True).exists() or is_project_admin, - 'permissions': permissions + "admin": group_permissions.filter(admin=True).exists() + or user_permissions.filter(admin=True).exists() + or is_project_admin, + "permissions": permissions, } serializer = MyUserObjectPermissionsSerializer(data=data) @@ -118,21 +176,30 @@ def user_permissions(self, request, *args, **kwargs): return Response(serializer.data) -class WebhookViewSet(mixins.ListModelMixin, mixins.CreateModelMixin, mixins.UpdateModelMixin, mixins.DestroyModelMixin, - viewsets.GenericViewSet): +class WebhookViewSet( + mixins.ListModelMixin, + mixins.CreateModelMixin, + mixins.UpdateModelMixin, + mixins.DestroyModelMixin, + viewsets.GenericViewSet, +): serializer_class = WebhookSerializer pagination_class = None permission_classes = [IsAuthenticated, NestedEnvironmentPermissions] def get_queryset(self): - return Webhook.objects.filter(environment__api_key=self.kwargs.get('environment_api_key')) + return Webhook.objects.filter( + environment__api_key=self.kwargs.get("environment_api_key") + ) def perform_create(self, serializer): - environment = Environment.objects.get(api_key=self.kwargs.get('environment_api_key')) + environment = Environment.objects.get( + api_key=self.kwargs.get("environment_api_key") + ) serializer.save(environment=environment) def perform_update(self, serializer): - environment = Environment.objects.get(api_key=self.kwargs.get('environment_api_key')) + environment = Environment.objects.get( + api_key=self.kwargs.get("environment_api_key") + ) serializer.save(environment=environment) - - diff --git a/src/features/__init__.py b/src/features/__init__.py index 549beb90859b..bafad0e98f78 100644 --- a/src/features/__init__.py +++ b/src/features/__init__.py @@ -1 +1 @@ -default_app_config = 'features.apps.FeaturesConfig' +default_app_config = "features.apps.FeaturesConfig" diff --git a/src/features/admin.py b/src/features/admin.py index a9dd7bc2454e..b20cbdf39a7c 100644 --- a/src/features/admin.py +++ b/src/features/admin.py @@ -5,7 +5,7 @@ from django.contrib import admin from simple_history.admin import SimpleHistoryAdmin -from .models import Feature, FeatureState, FeatureStateValue, FeatureSegment +from .models import Feature, FeatureSegment, FeatureState, FeatureStateValue class FeatureStateValueInline(admin.StackedInline): @@ -15,17 +15,27 @@ class FeatureStateValueInline(admin.StackedInline): class FeatureAdmin(SimpleHistoryAdmin): - date_hierarchy = 'created_date' - list_display = ('__str__', 'initial_value', - 'default_enabled', 'type', 'created_date', ) - list_filter = ('type', 'default_enabled', 'created_date', 'project', ) - list_select_related = ('project', ) + date_hierarchy = "created_date" + list_display = ( + "__str__", + "initial_value", + "default_enabled", + "type", + "created_date", + ) + list_filter = ( + "type", + "default_enabled", + "created_date", + "project", + ) + list_select_related = ("project",) search_fields = ( - 'project__name', - 'name', - 'initial_value', - 'description', - 'tags__label' + "project__name", + "name", + "initial_value", + "description", + "tags__label", ) @@ -33,7 +43,7 @@ class FeatureSegmentAdmin(admin.ModelAdmin): model = FeatureSegment def add_view(self, *args, **kwargs): - self.exclude = ('priority',) + self.exclude = ("priority",) return super(FeatureSegmentAdmin, self).add_view(*args, **kwargs) def change_view(self, *args, **kwargs): @@ -45,34 +55,53 @@ class FeatureStateAdmin(SimpleHistoryAdmin): inlines = [ FeatureStateValueInline, ] - list_display = ('__str__', 'enabled', ) - list_filter = ('enabled', 'environment', 'feature', ) - list_select_related = ('environment', 'feature', 'identity', ) - raw_id_fields = ('identity', ) + list_display = ( + "__str__", + "enabled", + ) + list_filter = ( + "enabled", + "environment", + "feature", + ) + list_select_related = ( + "environment", + "feature", + "identity", + ) + raw_id_fields = ("identity",) search_fields = ( - 'feature__name', - 'feature__project__name', - 'environment__name', - 'identity__identifier', + "feature__name", + "feature__project__name", + "environment__name", + "identity__identifier", ) class FeatureStateValueAdmin(SimpleHistoryAdmin): - list_display = ('feature_state', 'type', 'boolean_value', - 'integer_value', 'string_value', ) - list_filter = ('type', 'boolean_value', ) - list_select_related = ('feature_state',) - raw_id_fields = ('feature_state', ) + list_display = ( + "feature_state", + "type", + "boolean_value", + "integer_value", + "string_value", + ) + list_filter = ( + "type", + "boolean_value", + ) + list_select_related = ("feature_state",) + raw_id_fields = ("feature_state",) search_fields = ( - 'string_value', - 'feature_state__feature__name', - 'feature_state__feature__project__name', - 'feature_state__environment__name', - 'feature_state__identity__identifier', + "string_value", + "feature_state__feature__name", + "feature_state__feature__project__name", + "feature_state__environment__name", + "feature_state__identity__identifier", ) -if settings.ENV in ('local', 'dev'): +if settings.ENV in ("local", "dev"): admin.site.register(Feature, FeatureAdmin) admin.site.register(FeatureState, FeatureStateAdmin) admin.site.register(FeatureSegment, FeatureSegmentAdmin) diff --git a/src/features/apps.py b/src/features/apps.py index eee08e740595..684c0386a25d 100644 --- a/src/features/apps.py +++ b/src/features/apps.py @@ -5,7 +5,7 @@ class FeaturesConfig(AppConfig): - name = 'features' + name = "features" def ready(self): # noinspection PyUnresolvedReferences diff --git a/src/features/fields.py b/src/features/fields.py index 651d32ae932f..a3fdaee8f92a 100644 --- a/src/features/fields.py +++ b/src/features/fields.py @@ -1,6 +1,6 @@ from rest_framework import serializers -from features.utils import INTEGER, BOOLEAN, STRING +from features.utils import BOOLEAN, INTEGER, STRING class FeatureSegmentValueField(serializers.Field): @@ -11,7 +11,7 @@ def to_internal_value(self, data): 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 + self.context["value_type"] = value_type return str(data) diff --git a/src/features/helpers.py b/src/features/helpers.py index d2fa260bc178..1f8701dbd15a 100644 --- a/src/features/helpers.py +++ b/src/features/helpers.py @@ -1,13 +1,12 @@ import typing -from features.utils import INTEGER, BOOLEAN +from features.utils import BOOLEAN, INTEGER 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 == "True" return string_value - diff --git a/src/features/models.py b/src/features/models.py index a87ff5fdb73b..7f0bcd16a087 100644 --- a/src/features/models.py +++ b/src/features/models.py @@ -1,7 +1,10 @@ from __future__ import unicode_literals -from django.core.exceptions import (NON_FIELD_ERRORS, ObjectDoesNotExist, - ValidationError) +from django.core.exceptions import ( + NON_FIELD_ERRORS, + ObjectDoesNotExist, + ValidationError, +) from django.db import models from django.utils.encoding import python_2_unicode_compatible from django.utils.translation import ugettext_lazy as _ @@ -10,7 +13,14 @@ from features.helpers import get_correctly_typed_value from features.tasks import trigger_feature_state_change_webhooks -from features.utils import get_boolean_from_string, get_integer_from_string, INTEGER, STRING, BOOLEAN, get_value_type +from features.utils import ( + BOOLEAN, + INTEGER, + STRING, + get_boolean_from_string, + get_integer_from_string, + get_value_type, +) from projects.models import Project from projects.tags.models import Tag from util.logging import get_logger @@ -18,35 +28,32 @@ logger = get_logger(__name__) # Feature Types -FLAG = 'FLAG' -CONFIG = 'CONFIG' +FLAG = "FLAG" +CONFIG = "CONFIG" FEATURE_STATE_VALUE_TYPES = ( - (INTEGER, 'Integer'), - (STRING, 'String'), - (BOOLEAN, 'Boolean') + (INTEGER, "Integer"), + (STRING, "String"), + (BOOLEAN, "Boolean"), ) @python_2_unicode_compatible class Feature(models.Model): - FEATURE_TYPES = ( - (FLAG, 'Feature Flag'), - (CONFIG, 'Remote Config') - ) + FEATURE_TYPES = ((FLAG, "Feature Flag"), (CONFIG, "Remote Config")) name = models.CharField(max_length=2000) - created_date = models.DateTimeField('DateCreated', auto_now_add=True) + created_date = models.DateTimeField("DateCreated", auto_now_add=True) project = models.ForeignKey( Project, - related_name='features', + related_name="features", 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. Also this will remove any Tags associated with a feature as Tags are Project defined' + "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. Also this will remove any Tags associated with a feature as Tags are Project defined" ), - on_delete=models.CASCADE + on_delete=models.CASCADE, ) initial_value = models.CharField(max_length=2000, null=True, default=None) description = models.TextField(null=True, blank=True) @@ -56,14 +63,14 @@ class Feature(models.Model): tags = models.ManyToManyField(Tag, blank=True) class Meta: - ordering = ['id'] + ordering = ["id"] # Note: uniqueness is changed to reference lowercase name in explicit SQL in the migrations - unique_together = ('name', 'project') + unique_together = ("name", "project") def save(self, *args, **kwargs): - ''' + """ Override save method to initialise feature states for all environments - ''' + """ if self.pk: # If the feature has moved to a new project, delete the feature states from the old project old_feature = Feature.objects.get(pk=self.pk) @@ -83,35 +90,39 @@ def save(self, *args, **kwargs): environment=env, identity=None, feature_segment=None, - defaults={ - 'enabled': self.default_enabled - }, + defaults={"enabled": self.default_enabled}, ) def validate_unique(self, *args, **kwargs): - ''' + """ Checks unique constraints on the model and raises ``ValidationError`` if any failed. - ''' + """ super(Feature, self).validate_unique(*args, **kwargs) # handle case insensitive names per project, as above check allows it - if Feature.objects.filter(project=self.project, name__iexact=self.name).exclude(pk=self.pk).exists(): + if ( + Feature.objects.filter(project=self.project, name__iexact=self.name) + .exclude(pk=self.pk) + .exists() + ): raise ValidationError( { NON_FIELD_ERRORS: [ - 'Feature with that name already exists for this project. Note that feature ' - 'names are case insensitive.', + "Feature with that name already exists for this project. Note that feature " + "names are case insensitive.", ], } ) def __str__(self): - return 'Project %s - Feature %s' % (self.project.name, self.name) + return "Project %s - Feature %s" % (self.project.name, self.name) def get_next_segment_priority(feature): - feature_segments = FeatureSegment.objects.filter(feature=feature).order_by('-priority') + feature_segments = FeatureSegment.objects.filter(feature=feature).order_by( + "-priority" + ) if feature_segments.count() == 0: return 1 else: @@ -120,85 +131,128 @@ def get_next_segment_priority(feature): @python_2_unicode_compatible class FeatureSegment(OrderedModelBase): - feature = models.ForeignKey(Feature, on_delete=models.CASCADE, related_name='feature_segments') - segment = models.ForeignKey('segments.Segment', related_name='feature_segments', on_delete=models.CASCADE) + feature = models.ForeignKey( + Feature, on_delete=models.CASCADE, related_name="feature_segments" + ) + segment = models.ForeignKey( + "segments.Segment", related_name="feature_segments", on_delete=models.CASCADE + ) environment = models.ForeignKey( - 'environments.Environment', on_delete=models.CASCADE, related_name='feature_segments' + "environments.Environment", + on_delete=models.CASCADE, + related_name="feature_segments", ) enabled = models.BooleanField(default=False) value = models.CharField(max_length=2000, blank=True, null=True) - value_type = models.CharField(choices=FEATURE_STATE_VALUE_TYPES, max_length=50, blank=True, null=True) + value_type = models.CharField( + choices=FEATURE_STATE_VALUE_TYPES, max_length=50, blank=True, null=True + ) # specific attributes for managing the order of feature segments priority = models.PositiveIntegerField(editable=False, db_index=True) - order_field_name = 'priority' - order_with_respect_to = ('feature', 'environment') + order_field_name = "priority" + order_with_respect_to = ("feature", "environment") # used for audit purposes history = HistoricalRecords() class Meta: - unique_together = ('feature', 'environment', 'segment') - ordering = ('priority',) + unique_together = ("feature", "environment", "segment") + ordering = ("priority",) def save(self, *args, **kwargs): super(FeatureSegment, self).save(*args, **kwargs) # update or create feature state for environment FeatureState.objects.update_or_create( - environment=self.environment, feature=self.feature, feature_segment=self, defaults={"enabled": self.enabled} + environment=self.environment, + feature=self.feature, + feature_segment=self, + defaults={"enabled": self.enabled}, ) def __str__(self): - return 'FeatureSegment for ' + self.feature.name + ' with priority ' + str(self.priority) + return ( + "FeatureSegment for " + + self.feature.name + + " with priority " + + str(self.priority) + ) # noinspection PyTypeChecker def get_value(self): return get_correctly_typed_value(self.value_type, self.value) def __lt__(self, other): - ''' + """ Kind of counter intuitive but since priority 1 is highest, we want to check if priority is GREATER than the priority of the other feature segment. - ''' + """ return other and self.priority > other.priority @python_2_unicode_compatible class FeatureState(models.Model): - feature = models.ForeignKey(Feature, related_name='feature_states', on_delete=models.CASCADE) - environment = models.ForeignKey('environments.Environment', related_name='feature_states', null=True, - on_delete=models.CASCADE) - identity = models.ForeignKey('identities.Identity', related_name='identity_features', - null=True, default=None, blank=True, on_delete=models.CASCADE) - feature_segment = models.ForeignKey(FeatureSegment, related_name='feature_states', null=True, blank=True, - default=None, on_delete=models.CASCADE) + feature = models.ForeignKey( + Feature, related_name="feature_states", on_delete=models.CASCADE + ) + environment = models.ForeignKey( + "environments.Environment", + related_name="feature_states", + null=True, + on_delete=models.CASCADE, + ) + identity = models.ForeignKey( + "identities.Identity", + related_name="identity_features", + null=True, + default=None, + blank=True, + on_delete=models.CASCADE, + ) + feature_segment = models.ForeignKey( + FeatureSegment, + related_name="feature_states", + null=True, + blank=True, + default=None, + on_delete=models.CASCADE, + ) enabled = models.BooleanField(default=False) history = HistoricalRecords() class Meta: - unique_together = (('feature', 'environment', 'identity'), ('feature', 'environment', 'feature_segment')) - ordering = ['id'] + unique_together = ( + ("feature", "environment", "identity"), + ("feature", "environment", "feature_segment"), + ) + ordering = ["id"] def __gt__(self, other): - ''' + """ Checks if the current feature state is higher priority that the provided feature state. :param other: (FeatureState) the feature state to compare the priority of :return: True if self is higher priority than other - ''' + """ if self.environment != other.environment: - raise ValueError('Cannot compare feature states as they belong to different environments.') + raise ValueError( + "Cannot compare feature states as they belong to different environments." + ) if self.feature != other.feature: - raise ValueError('Cannot compare feature states as they belong to different features.') + raise ValueError( + "Cannot compare feature states as they belong to different features." + ) if self.identity: # identity is the highest priority so we can always return true if other.identity and self.identity != other.identity: - raise ValueError('Cannot compare feature states as they are for different identities.') + raise ValueError( + "Cannot compare feature states as they are for different identities." + ) return True if self.feature_segment: @@ -222,7 +276,7 @@ def get_feature_state_value(self): type_mapping = { INTEGER: self.feature_state_value.integer_value, STRING: self.feature_state_value.string_value, - BOOLEAN: self.feature_state_value.boolean_value + BOOLEAN: self.feature_state_value.boolean_value, } return type_mapping.get(value_type) @@ -242,16 +296,23 @@ def previous_feature_state_value(self): type_mapping = { INTEGER: previous_feature_state_value.integer_value, STRING: previous_feature_state_value.string_value, - BOOLEAN: previous_feature_state_value.boolean_value + BOOLEAN: previous_feature_state_value.boolean_value, } return type_mapping.get(value_type) def save(self, *args, **kwargs): # prevent duplicate feature states being created for an environment - if not self.pk and FeatureState.objects.filter(environment=self.environment, feature=self.feature).exists() \ - and not (self.identity or self.feature_segment): - raise ValidationError('Feature state already exists for this environment and feature') + if ( + not self.pk + and FeatureState.objects.filter( + environment=self.environment, feature=self.feature + ).exists() + and not (self.identity or self.feature_segment) + ): + raise ValidationError( + "Feature state already exists for this environment and feature" + ) super(FeatureState, self).save(*args, **kwargs) @@ -261,8 +322,7 @@ def save(self, *args, **kwargs): # Note: feature segments are handled differently as they have their own values if not self.feature_segment and self.feature.type == CONFIG: FeatureStateValue.objects.get_or_create( - feature_state=self, - defaults=self._get_defaults() + feature_state=self, defaults=self._get_defaults() ) # TODO: move this to an async call using celery or django-rq trigger_feature_state_change_webhooks(self) @@ -274,9 +334,7 @@ def _get_defaults(self): return self._get_defaults_for_environment_feature_state() def _get_defaults_for_segment_feature_state(self): - defaults = { - 'type': self.feature_segment.value_type - } + defaults = {"type": self.feature_segment.value_type} key_name = self._get_feature_state_key_name(self.feature_segment.value_type) @@ -298,9 +356,7 @@ def _get_defaults_for_environment_feature_state(self): value = self.feature.initial_value type = get_value_type(value) - defaults = { - 'type': type - } + defaults = {"type": type} key_name = self._get_feature_state_key_name(type) if type == BOOLEAN: @@ -315,47 +371,61 @@ def _get_defaults_for_environment_feature_state(self): @staticmethod def _get_feature_state_key_name(fsv_type): return { - INTEGER: 'integer_value', - BOOLEAN: 'boolean_value', - STRING: 'string_value', - }.get(fsv_type, 'string_value') # The default was chosen for backwards compatibility + INTEGER: "integer_value", + BOOLEAN: "boolean_value", + STRING: "string_value", + }.get( + fsv_type, "string_value" + ) # The default was chosen for backwards compatibility def generate_feature_state_value_data(self, value): - ''' + """ Takes the value of a feature state to generate a feature state value and returns dictionary to use for passing into feature state value serializer :param value: feature state value of variable type :return: dictionary to pass directly into feature state value serializer - ''' + """ fsv_type = type(value).__name__ accepted_types = (STRING, INTEGER, BOOLEAN) return { # Default to string if not an anticipate type value to keep backwards compatibility. - 'type': fsv_type if fsv_type in accepted_types else STRING, - 'feature_state': self.id, - self._get_feature_state_key_name(fsv_type): value + "type": fsv_type if fsv_type in accepted_types else STRING, + "feature_state": self.id, + self._get_feature_state_key_name(fsv_type): value, } def __str__(self): if self.environment is not None: - return 'Project %s - Environment %s - Feature %s - Enabled: %r' % \ - (self.environment.project.name, - self.environment.name, self.feature.name, - self.enabled) + return "Project %s - Environment %s - Feature %s - Enabled: %r" % ( + self.environment.project.name, + self.environment.name, + self.feature.name, + self.enabled, + ) elif self.identity is not None: - return 'Identity %s - Feature %s - Enabled: %r' % (self.identity.identifier, - self.feature.name, self.enabled) + return "Identity %s - Feature %s - Enabled: %r" % ( + self.identity.identifier, + self.feature.name, + self.enabled, + ) else: - return 'Feature %s - Enabled: %r' % (self.feature.name, self.enabled) + return "Feature %s - Enabled: %r" % (self.feature.name, self.enabled) class FeatureStateValue(models.Model): - feature_state = models.OneToOneField(FeatureState, related_name='feature_state_value', on_delete=models.CASCADE) + feature_state = models.OneToOneField( + FeatureState, related_name="feature_state_value", on_delete=models.CASCADE + ) - type = models.CharField(max_length=10, choices=FEATURE_STATE_VALUE_TYPES, default=STRING, - null=True, blank=True) + type = models.CharField( + max_length=10, + choices=FEATURE_STATE_VALUE_TYPES, + default=STRING, + null=True, + blank=True, + ) boolean_value = models.NullBooleanField(null=True, blank=True) integer_value = models.IntegerField(null=True, blank=True) string_value = models.CharField(null=True, max_length=2000, blank=True) diff --git a/src/features/permissions.py b/src/features/permissions.py index cdc005ebcb19..460de17cb532 100644 --- a/src/features/permissions.py +++ b/src/features/permissions.py @@ -4,21 +4,23 @@ from projects.models import Project ACTION_PERMISSIONS_MAP = { - 'retrieve': 'VIEW_PROJECT', - 'destroy': 'DELETE_FEATURE', - 'list': 'VIEW_PROJECT', - 'create': 'CREATE_FEATURE' + "retrieve": "VIEW_PROJECT", + "destroy": "DELETE_FEATURE", + "list": "VIEW_PROJECT", + "create": "CREATE_FEATURE", } class FeaturePermissions(BasePermission): def has_permission(self, request, view): try: - project_id = view.kwargs.get('project_pk') or request.data.get('project') + project_id = view.kwargs.get("project_pk") or request.data.get("project") project = Project.objects.get(id=project_id) if view.action in ACTION_PERMISSIONS_MAP: - return request.user.has_project_permission(ACTION_PERMISSIONS_MAP.get(view.action), project) + return request.user.has_project_permission( + ACTION_PERMISSIONS_MAP.get(view.action), project + ) # move on to object specific permissions return view.detail @@ -29,9 +31,11 @@ def has_permission(self, request, view): def has_object_permission(self, request, view, obj): # map of actions and their required permission if view.action in ACTION_PERMISSIONS_MAP: - return request.user.has_project_permission(ACTION_PERMISSIONS_MAP[view.action], obj.project) + return request.user.has_project_permission( + ACTION_PERMISSIONS_MAP[view.action], obj.project + ) - if view.action in ('update', 'segments'): + if view.action in ("update", "segments"): return request.user.is_project_admin(obj.project) return False @@ -40,9 +44,9 @@ def has_object_permission(self, request, view, obj): class FeatureStatePermissions(BasePermission): def has_permission(self, request, view): try: - if view.action == 'create': - if request.data.get('environment'): - environment = Environment.objects.get(request.data['environment']) + if view.action == "create": + if request.data.get("environment"): + environment = Environment.objects.get(request.data["environment"]) return request.user.is_environment_admin(environment) # detail view so we can check defer to object permissions diff --git a/src/features/serializers.py b/src/features/serializers.py index 3cbcd15378ac..d03a687c7ccb 100644 --- a/src/features/serializers.py +++ b/src/features/serializers.py @@ -1,30 +1,41 @@ from rest_framework import serializers from rest_framework.exceptions import ValidationError -from audit.models import AuditLog, RelatedObjectType, FEATURE_CREATED_MESSAGE, FEATURE_UPDATED_MESSAGE, \ - FEATURE_STATE_UPDATED_MESSAGE, IDENTITY_FEATURE_STATE_UPDATED_MESSAGE +from audit.models import ( + FEATURE_CREATED_MESSAGE, + FEATURE_STATE_UPDATED_MESSAGE, + FEATURE_UPDATED_MESSAGE, + IDENTITY_FEATURE_STATE_UPDATED_MESSAGE, + AuditLog, + RelatedObjectType, +) from environments.identities.models import Identity from features.utils import BOOLEAN, INTEGER, STRING + from .fields import FeatureSegmentValueField -from .models import Feature, FeatureState, FeatureStateValue, FeatureSegment +from .models import Feature, FeatureSegment, FeatureState, FeatureStateValue class CreateFeatureSerializer(serializers.ModelSerializer): class Meta: model = Feature fields = "__all__" - read_only_fields = ('feature_segments',) + read_only_fields = ("feature_segments",) def to_internal_value(self, data): - if data.get('initial_value'): - data['initial_value'] = str(data.get('initial_value')) + if data.get("initial_value"): + data["initial_value"] = str(data.get("initial_value")) return super(CreateFeatureSerializer, self).to_internal_value(data) def create(self, validated_data): - if Feature.objects.filter(project=validated_data['project'], name__iexact=validated_data['name']).exists(): - raise serializers.ValidationError("Feature with that name already exists for this " - "project. Note that feature names are case " - "insensitive.") + if Feature.objects.filter( + project=validated_data["project"], name__iexact=validated_data["name"] + ).exists(): + raise serializers.ValidationError( + "Feature with that name already exists for this " + "project. Note that feature names are case " + "insensitive." + ) instance = super(CreateFeatureSerializer, self).create(validated_data) @@ -37,17 +48,26 @@ def update(self, instance, validated_data): return super(CreateFeatureSerializer, self).update(instance, validated_data) def _create_audit_log(self, instance, created): - message = FEATURE_CREATED_MESSAGE % instance.name if created else FEATURE_UPDATED_MESSAGE % instance.name - request = self.context.get('request') - AuditLog.objects.create(author=request.user if request else None, related_object_id=instance.id, - related_object_type=RelatedObjectType.FEATURE.name, - project=instance.project, - log=message) + message = ( + FEATURE_CREATED_MESSAGE % instance.name + if created + else FEATURE_UPDATED_MESSAGE % instance.name + ) + request = self.context.get("request") + AuditLog.objects.create( + author=request.user if request else None, + related_object_id=instance.id, + related_object_type=RelatedObjectType.FEATURE.name, + project=instance.project, + log=message, + ) def validate(self, attrs): # If tags selected check they from the same Project as Feature Project - if any(tag.project_id != attrs['project'].id for tag in attrs.get('tags', [])): - raise ValidationError("Selected Tags must be from the same Project as current Feature") + if any(tag.project_id != attrs["project"].id for tag in attrs.get("tags", [])): + raise ValidationError( + "Selected Tags must be from the same Project as current Feature" + ) return attrs @@ -57,16 +77,29 @@ class FeatureSegmentCreateSerializer(serializers.ModelSerializer): class Meta: model = FeatureSegment - fields = ('id', 'feature', 'segment', 'environment', 'priority', 'enabled', 'value') - read_only_fields = ('id', 'priority',) + fields = ( + "id", + "feature", + "segment", + "environment", + "priority", + "enabled", + "value", + ) + read_only_fields = ( + "id", + "priority", + ) def create(self, validated_data): - validated_data['value_type'] = self.context.get('value_type', STRING) + validated_data["value_type"] = self.context.get("value_type", STRING) return super(FeatureSegmentCreateSerializer, self).create(validated_data) def update(self, instance, validated_data): - validated_data['value_type'] = self.context.get('value_type', STRING) - return super(FeatureSegmentCreateSerializer, self).update(instance, validated_data) + validated_data["value_type"] = self.context.get("value_type", STRING) + return super(FeatureSegmentCreateSerializer, self).update( + instance, validated_data + ) class FeatureSegmentQuerySerializer(serializers.Serializer): @@ -79,26 +112,37 @@ class FeatureSegmentListSerializer(serializers.ModelSerializer): class Meta: model = FeatureSegment - fields = ('id', 'segment', 'priority', 'environment', 'enabled', 'value') - read_only_fields = ('id', 'segment', 'priority', 'environment', 'enabled', 'value') + fields = ("id", "segment", "priority", "environment", "enabled", "value") + read_only_fields = ( + "id", + "segment", + "priority", + "environment", + "enabled", + "value", + ) def get_value(self, instance): return instance.get_value() class FeatureSegmentChangePrioritiesSerializer(serializers.Serializer): - priority = serializers.IntegerField(min_value=0, help_text="Value to change the feature segment's priority to.") + priority = serializers.IntegerField( + min_value=0, help_text="Value to change the feature segment's priority to." + ) id = serializers.IntegerField() def create(self, validated_data): try: - instance = FeatureSegment.objects.get(id=validated_data['id']) + instance = FeatureSegment.objects.get(id=validated_data["id"]) return self.update(instance, validated_data) except FeatureSegment.DoesNotExist: - raise ValidationError("No feature segment exists with id: %s" % validated_data['id']) + raise ValidationError( + "No feature segment exists with id: %s" % validated_data["id"] + ) def update(self, instance, validated_data): - instance.to(validated_data['priority']) + instance.to(validated_data["priority"]) return instance @@ -112,7 +156,7 @@ class Meta: "description", "initial_value", "default_enabled", - "type" + "type", ) writeonly_fields = ("initial_value", "default_enabled") @@ -128,11 +172,9 @@ class Meta: "description", "default_enabled", "type", - "tags" - ) - writeonly_fields = ( - "initial_value", "default_enabled" + "tags", ) + writeonly_fields = ("initial_value", "default_enabled") class FeatureStateSerializerFull(serializers.ModelSerializer): @@ -163,16 +205,18 @@ def create(self, validated_data): return instance def update(self, instance, validated_data): - updated_instance = super(FeatureStateSerializerBasic, self).update(instance, validated_data) + updated_instance = super(FeatureStateSerializerBasic, self).update( + instance, validated_data + ) self._create_audit_log(updated_instance) return updated_instance def _create_audit_log(self, instance): - create_feature_state_audit_log(instance, self.context.get('request')) + create_feature_state_audit_log(instance, self.context.get("request")) def validate(self, attrs): - if attrs.get('identity') and attrs.get('environment'): - if not attrs['identity'].environment == attrs['environment']: + if attrs.get("identity") and attrs.get("environment"): + if not attrs["identity"].environment == attrs["environment"]: raise ValidationError("Identity does not exist in environment.") return attrs @@ -181,7 +225,7 @@ class FeatureStateSerializerWithIdentity(FeatureStateSerializerBasic): class _IdentitySerializer(serializers.ModelSerializer): class Meta: model = Identity - fields = ('id', 'identifier') + fields = ("id", "identifier") identity = _IdentitySerializer() @@ -196,7 +240,7 @@ def get_identity_identifier(self, instance): class FeatureStateSerializerCreate(serializers.ModelSerializer): class Meta: model = FeatureState - fields = ('feature', 'enabled') + fields = ("feature", "enabled") def create(self, validated_data): instance = super(FeatureStateSerializerCreate, self).create(validated_data) @@ -204,22 +248,26 @@ def create(self, validated_data): return instance def _create_audit_log(self, instance): - create_feature_state_audit_log(instance, self.context.get('request')) + create_feature_state_audit_log(instance, self.context.get("request")) def create_feature_state_audit_log(feature_state, request): if feature_state.identity: - message = IDENTITY_FEATURE_STATE_UPDATED_MESSAGE % (feature_state.feature.name, - feature_state.identity.identifier) + message = IDENTITY_FEATURE_STATE_UPDATED_MESSAGE % ( + feature_state.feature.name, + feature_state.identity.identifier, + ) else: message = FEATURE_STATE_UPDATED_MESSAGE % feature_state.feature.name - AuditLog.objects.create(author=request.user if request else None, - related_object_id=feature_state.id, - related_object_type=RelatedObjectType.FEATURE_STATE.name, - environment=feature_state.environment, - project=feature_state.environment.project, - log=message) + AuditLog.objects.create( + author=request.user if request else None, + related_object_id=feature_state.id, + related_object_type=RelatedObjectType.FEATURE_STATE.name, + environment=feature_state.environment, + project=feature_state.environment.project, + log=message, + ) class FeatureStateValueSerializer(serializers.ModelSerializer): diff --git a/src/features/signals.py b/src/features/signals.py index 8003e55b7858..e4c253e70b60 100644 --- a/src/features/signals.py +++ b/src/features/signals.py @@ -1,8 +1,13 @@ from django.dispatch import receiver from simple_history.signals import post_create_historical_record -from audit.models import AuditLog, RelatedObjectType, FEATURE_SEGMENT_UPDATED_MESSAGE +from audit.models import ( + FEATURE_SEGMENT_UPDATED_MESSAGE, + AuditLog, + RelatedObjectType, +) from util.logging import get_logger + # noinspection PyUnresolvedReferences from .models import HistoricalFeatureSegment @@ -10,17 +15,22 @@ @receiver(post_create_historical_record, sender=HistoricalFeatureSegment) -def create_feature_segment_audit_log(instance, history_user, history_instance, **kwargs): +def create_feature_segment_audit_log( + instance, history_user, history_instance, **kwargs +): # due to referential integrity issues that come from cascade deletes, we skip creating # audit logs for deleted feature segments for now # TODO: handle audit log in middleware instead project = None if history_instance.history_type == "-" else instance.feature.project - message = FEATURE_SEGMENT_UPDATED_MESSAGE % (instance.feature.name, instance.environment.name) + message = FEATURE_SEGMENT_UPDATED_MESSAGE % ( + instance.feature.name, + instance.environment.name, + ) AuditLog.create_record( obj=instance.feature, obj_type=RelatedObjectType.FEATURE, log_message=message, author=history_user, - project=project + project=project, ) diff --git a/src/features/tasks.py b/src/features/tasks.py index 58ab9d00b8c7..fb13e04efb68 100644 --- a/src/features/tasks.py +++ b/src/features/tasks.py @@ -1,8 +1,8 @@ from threading import Thread from webhooks.webhooks import ( - call_environment_webhooks, WebhookEventType, + call_environment_webhooks, call_organisation_webhooks, ) diff --git a/src/features/tests/test_fields.py b/src/features/tests/test_fields.py index 698f523f5aaf..3b8d3f9cd349 100644 --- a/src/features/tests/test_fields.py +++ b/src/features/tests/test_fields.py @@ -2,15 +2,18 @@ from rest_framework import serializers from features.fields import FeatureSegmentValueField -from features.utils import STRING, BOOLEAN, INTEGER +from features.utils import BOOLEAN, INTEGER, STRING -@pytest.mark.parametrize("value, expected_type", [ - ["string", STRING], - [True, BOOLEAN], - [False, BOOLEAN], - [123, INTEGER], -]) +@pytest.mark.parametrize( + "value, expected_type", + [ + ["string", STRING], + [True, BOOLEAN], + [False, BOOLEAN], + [123, INTEGER], + ], +) def test_feature_segment_field_to_representation(value, expected_type): # Given class MySerializer(serializers.Serializer): @@ -21,5 +24,5 @@ class MySerializer(serializers.Serializer): internal_value = serializer.to_internal_value({"my_field": value}) # Then - assert internal_value['my_field'] == str(value) - assert serializer.context['value_type'] == expected_type + assert internal_value["my_field"] == str(value) + assert serializer.context["value_type"] == expected_type diff --git a/src/features/tests/test_migrations.py b/src/features/tests/test_migrations.py index c3f0e4238700..7830e97b8412 100644 --- a/src/features/tests/test_migrations.py +++ b/src/features/tests/test_migrations.py @@ -1,42 +1,58 @@ - - def test_migrate_feature_segments_forward(migrator): # Given - the migration state is at 0017 (before the migration we want to test) - old_state = migrator.apply_initial_migration(('features', '0017_auto_20200607_1005')) - OldFeatureSegment = old_state.apps.get_model('features', 'FeatureSegment') - OldFeatureState = old_state.apps.get_model('features', 'FeatureState') + old_state = migrator.apply_initial_migration( + ("features", "0017_auto_20200607_1005") + ) + OldFeatureSegment = old_state.apps.get_model("features", "FeatureSegment") + OldFeatureState = old_state.apps.get_model("features", "FeatureState") # use the migration state to get the classes we need for test data - Feature = old_state.apps.get_model('features', 'Feature') - Organisation = old_state.apps.get_model('organisations', 'Organisation') - Project = old_state.apps.get_model('projects', 'Project') - Segment = old_state.apps.get_model('segments', 'Segment') - Environment = old_state.apps.get_model('environments', 'Environment') + Feature = old_state.apps.get_model("features", "Feature") + Organisation = old_state.apps.get_model("organisations", "Organisation") + Project = old_state.apps.get_model("projects", "Project") + Segment = old_state.apps.get_model("segments", "Segment") + Environment = old_state.apps.get_model("environments", "Environment") # setup some test data - organisation = Organisation.objects.create(name='Test Organisation') - project = Project.objects.create(name='Test project', organisation=organisation) - feature = Feature.objects.create(name='Test feature', project=project) - segment_1 = Segment.objects.create(name='Test segment 1', project=project) - segment_2 = Segment.objects.create(name='Test segment 2', project=project) - environment_1 = Environment.objects.create(name='Test environment 1', project=project) - environment_2 = Environment.objects.create(name='Test environment 2', project=project) + organisation = Organisation.objects.create(name="Test Organisation") + project = Project.objects.create(name="Test project", organisation=organisation) + feature = Feature.objects.create(name="Test feature", project=project) + segment_1 = Segment.objects.create(name="Test segment 1", project=project) + segment_2 = Segment.objects.create(name="Test segment 2", project=project) + environment_1 = Environment.objects.create( + name="Test environment 1", project=project + ) + environment_2 = Environment.objects.create( + name="Test environment 2", project=project + ) # create 2 feature segment without an environment and with enabled overridden to true - feature_segment_1 = OldFeatureSegment.objects.create(feature=feature, segment=segment_1, enabled=True, priority=0) - feature_segment_2 = OldFeatureSegment.objects.create(feature=feature, segment=segment_2, enabled=True, priority=1) + feature_segment_1 = OldFeatureSegment.objects.create( + feature=feature, segment=segment_1, enabled=True, priority=0 + ) + feature_segment_2 = OldFeatureSegment.objects.create( + feature=feature, segment=segment_2, enabled=True, priority=1 + ) # mimick the creation of the feature states that would have happened when save is called on the model (but doesn't # happen because we're using the migrator models) - OldFeatureState.objects.create(feature=feature, environment=environment_1, feature_segment=feature_segment_1) - OldFeatureState.objects.create(feature=feature, environment=environment_2, feature_segment=feature_segment_1) - OldFeatureState.objects.create(feature=feature, environment=environment_1, feature_segment=feature_segment_2) - OldFeatureState.objects.create(feature=feature, environment=environment_2, feature_segment=feature_segment_2) + OldFeatureState.objects.create( + feature=feature, environment=environment_1, feature_segment=feature_segment_1 + ) + OldFeatureState.objects.create( + feature=feature, environment=environment_2, feature_segment=feature_segment_1 + ) + OldFeatureState.objects.create( + feature=feature, environment=environment_1, feature_segment=feature_segment_2 + ) + OldFeatureState.objects.create( + feature=feature, environment=environment_2, feature_segment=feature_segment_2 + ) # When - new_state = migrator.apply_tested_migration(('features', '0018_auto_20200607_1057')) - NewFeatureSegment = new_state.apps.get_model('features', 'FeatureSegment') - NewFeatureState = new_state.apps.get_model('features', 'FeatureState') + new_state = migrator.apply_tested_migration(("features", "0018_auto_20200607_1057")) + NewFeatureSegment = new_state.apps.get_model("features", "FeatureSegment") + NewFeatureState = new_state.apps.get_model("features", "FeatureState") # Then - there are 4 feature segments, for each feature segment, create 1 for each environment assert NewFeatureSegment.objects.count() == 4 @@ -55,36 +71,54 @@ def test_migrate_feature_segments_forward(migrator): assert not NewFeatureSegment.objects.filter(environment__isnull=True).exists() # verify that the feature states are created / updated with the new feature segments - assert NewFeatureState.objects.values('feature_segment').distinct().count() == 4 + assert NewFeatureState.objects.values("feature_segment").distinct().count() == 4 def test_migrate_feature_segments_reverse(migrator): # Given - migration state is at 0018, after the migration we want to test in reverse - old_state = migrator.apply_initial_migration(('features', '0018_auto_20200607_1057')) - OldFeatureSegment = old_state.apps.get_model('features', 'FeatureSegment') + old_state = migrator.apply_initial_migration( + ("features", "0018_auto_20200607_1057") + ) + OldFeatureSegment = old_state.apps.get_model("features", "FeatureSegment") # use the migration state to get the classes we need for test data - Feature = old_state.apps.get_model('features', 'Feature') - Organisation = old_state.apps.get_model('organisations', 'Organisation') - Project = old_state.apps.get_model('projects', 'Project') - Segment = old_state.apps.get_model('segments', 'Segment') - Environment = old_state.apps.get_model('environments', 'Environment') + Feature = old_state.apps.get_model("features", "Feature") + Organisation = old_state.apps.get_model("organisations", "Organisation") + Project = old_state.apps.get_model("projects", "Project") + Segment = old_state.apps.get_model("segments", "Segment") + Environment = old_state.apps.get_model("environments", "Environment") # setup some test data - organisation = Organisation.objects.create(name='Test Organisation') - project = Project.objects.create(name='Test project', organisation=organisation) - feature = Feature.objects.create(name='Test feature', project=project) - segment = Segment.objects.create(name='Test segment', project=project) - environment_1 = Environment.objects.create(name='Test environment 1', project=project) - environment_2 = Environment.objects.create(name='Test environment 2', project=project) + organisation = Organisation.objects.create(name="Test Organisation") + project = Project.objects.create(name="Test project", organisation=organisation) + feature = Feature.objects.create(name="Test feature", project=project) + segment = Segment.objects.create(name="Test segment", project=project) + environment_1 = Environment.objects.create( + name="Test environment 1", project=project + ) + environment_2 = Environment.objects.create( + name="Test environment 2", project=project + ) # create a feature segment for each environment - OldFeatureSegment.objects.create(feature=feature, segment=segment, environment=environment_1, enabled=True, priority=0) - OldFeatureSegment.objects.create(feature=feature, segment=segment, environment=environment_2, enabled=False, priority=0) + OldFeatureSegment.objects.create( + feature=feature, + segment=segment, + environment=environment_1, + enabled=True, + priority=0, + ) + OldFeatureSegment.objects.create( + feature=feature, + segment=segment, + environment=environment_2, + enabled=False, + priority=0, + ) # When - new_state = migrator.apply_tested_migration(('features', '0017_auto_20200607_1005')) - NewFeatureSegment = new_state.apps.get_model('features', 'FeatureSegment') + new_state = migrator.apply_tested_migration(("features", "0017_auto_20200607_1005")) + NewFeatureSegment = new_state.apps.get_model("features", "FeatureSegment") # Then - there is only one feature segment left assert NewFeatureSegment.objects.count() == 1 diff --git a/src/features/tests/test_models.py b/src/features/tests/test_models.py index 024a111af85f..0e9910fba8d4 100644 --- a/src/features/tests/test_models.py +++ b/src/features/tests/test_models.py @@ -4,26 +4,38 @@ from django.core.exceptions import ValidationError from django.db.utils import IntegrityError from django.test import TestCase -from environments.models import Environment, STRING -from environments.identities.traits.models import Trait + from environments.identities.models import Identity -from features.models import Feature, FeatureState, CONFIG, FeatureSegment, FeatureStateValue, FLAG -from features.utils import INTEGER, BOOLEAN +from environments.identities.traits.models import Trait +from environments.models import STRING, Environment +from features.models import ( + CONFIG, + FLAG, + Feature, + FeatureSegment, + FeatureState, + FeatureStateValue, +) +from features.utils import BOOLEAN, INTEGER from organisations.models import Organisation from projects.models import Project -from segments.models import Segment, SegmentRule, Condition, EQUAL from projects.tags.models import Tag +from segments.models import EQUAL, Condition, Segment, SegmentRule @pytest.mark.django_db class FeatureTestCase(TestCase): def setUp(self): self.organisation = Organisation.objects.create(name="Test Org") - self.project = Project.objects.create(name="Test Project", organisation=self.organisation) - self.environment_one = Environment.objects.create(name="Test Environment 1", - project=self.project) - self.environment_two = Environment.objects.create(name="Test Environment 2", - project=self.project) + self.project = Project.objects.create( + name="Test Project", organisation=self.organisation + ) + self.environment_one = Environment.objects.create( + name="Test Environment 1", project=self.project + ) + self.environment_two = Environment.objects.create( + name="Test Environment 2", project=self.project + ) def test_feature_should_create_feature_states_for_environments(self): feature = Feature.objects.create(name="Test Feature", project=self.project) @@ -32,19 +44,34 @@ def test_feature_should_create_feature_states_for_environments(self): self.assertEquals(feature_states.count(), 2) - def test_creating_feature_with_initial_value_should_set_value_for_all_feature_states(self): - feature = Feature.objects.create(name="Test Feature", project=self.project, - initial_value="This is a value", type=CONFIG) + def test_creating_feature_with_initial_value_should_set_value_for_all_feature_states( + self, + ): + feature = Feature.objects.create( + name="Test Feature", + project=self.project, + initial_value="This is a value", + type=CONFIG, + ) feature_states = FeatureState.objects.filter(feature=feature) for feature_state in feature_states: - self.assertEquals(feature_state.get_feature_state_value(), "This is a value") + self.assertEquals( + feature_state.get_feature_state_value(), "This is a value" + ) - def test_creating_feature_with_integer_initial_value_should_set_integer_value_for_all_feature_states(self): + def test_creating_feature_with_integer_initial_value_should_set_integer_value_for_all_feature_states( + self, + ): # Given initial_value = 1 - feature = Feature.objects.create(name='Test feature', project=self.project, initial_value=initial_value, type=CONFIG) + feature = Feature.objects.create( + name="Test feature", + project=self.project, + initial_value=initial_value, + type=CONFIG, + ) # When feature_states = FeatureState.objects.filter(feature=feature) @@ -53,10 +80,17 @@ def test_creating_feature_with_integer_initial_value_should_set_integer_value_fo for feature_state in feature_states: assert feature_state.get_feature_state_value() == initial_value - def test_creating_feature_with_boolean_initial_value_should_set_boolean_value_for_all_feature_states(self): + def test_creating_feature_with_boolean_initial_value_should_set_boolean_value_for_all_feature_states( + self, + ): # Given initial_value = False - feature = Feature.objects.create(name='Test feature', project=self.project, initial_value=initial_value, type=CONFIG) + feature = Feature.objects.create( + name="Test feature", + project=self.project, + initial_value=initial_value, + type=CONFIG, + ) # When feature_states = FeatureState.objects.filter(feature=feature) @@ -71,7 +105,7 @@ def test_updating_feature_state_should_trigger_webhook(self): def test_cannot_create_feature_with_same_case_insensitive_name(self): # Given - feature_name = 'Test Feature' + feature_name = "Test Feature" feature_one = Feature(project=self.project, name=feature_name) feature_two = Feature(project=self.project, name=feature_name.lower()) @@ -85,8 +119,8 @@ def test_cannot_create_feature_with_same_case_insensitive_name(self): def test_updating_feature_name_should_update_feature_states(self): # Given - old_feature_name = 'old_feature' - new_feature_name = 'new_feature' + old_feature_name = "old_feature" + new_feature_name = "new_feature" feature = Feature.objects.create(project=self.project, name=old_feature_name) @@ -101,19 +135,28 @@ def test_cannot_create_feature_with_same_case_insensitive_name(self): # unit test to validate validate_unique() method # Given - feature_name = 'Test Feature' - Feature.objects.create(name=feature_name, type=CONFIG, initial_value='test', project=self.project) + feature_name = "Test Feature" + Feature.objects.create( + name=feature_name, type=CONFIG, initial_value="test", project=self.project + ) # When with self.assertRaises(ValidationError): - feature_two = Feature(name=feature_name.lower(), type=CONFIG, initial_value='test', project=self.project) + feature_two = Feature( + name=feature_name.lower(), + type=CONFIG, + initial_value="test", + project=self.project, + ) feature_two.full_clean() def test_updating_feature_should_allow_case_insensitive_name(self): # Given - feature_name = 'Test Feature' + feature_name = "Test Feature" - feature = Feature.objects.create(project=self.project, name=feature_name, initial_value='test') + feature = Feature.objects.create( + project=self.project, name=feature_name, initial_value="test" + ) # When feature.name = feature_name.lower() @@ -122,14 +165,18 @@ def test_updating_feature_should_allow_case_insensitive_name(self): def test_when_create_feature_with_tags_then_success(self): # Given - tag1 = Tag.objects.create(label='Test Tag', - color='#fffff', - description='Test Tag description', - project=self.project) - tag2 = Tag.objects.create(label='Test Tag', - color='#fffff', - description='Test Tag description', - project=self.project) + tag1 = Tag.objects.create( + label="Test Tag", + color="#fffff", + description="Test Tag description", + project=self.project, + ) + tag2 = Tag.objects.create( + label="Test Tag", + color="#fffff", + description="Test Tag description", + project=self.project, + ) feature = Feature.objects.create(project=self.project, name="test feature") # When @@ -144,47 +191,75 @@ def test_when_create_feature_with_tags_then_success(self): @pytest.mark.django_db class FeatureSegmentTest(TestCase): def setUp(self) -> None: - self.organisation = Organisation.objects.create(name='Test org') - self.project = Project.objects.create(name='Test project', organisation=self.organisation) - self.environment = Environment.objects.create(name='Test environment', project=self.project) + self.organisation = Organisation.objects.create(name="Test org") + self.project = Project.objects.create( + name="Test project", organisation=self.organisation + ) + self.environment = Environment.objects.create( + name="Test environment", project=self.project + ) - self.initial_value = 'test' - self.remote_config = Feature.objects.create(name='Remote Config', type=CONFIG, initial_value='test', - project=self.project) + self.initial_value = "test" + self.remote_config = Feature.objects.create( + name="Remote Config", + type=CONFIG, + initial_value="test", + project=self.project, + ) - self.segment = Segment.objects.create(name='Test segment', project=self.project) - segment_rule = SegmentRule.objects.create(segment=self.segment, type=SegmentRule.ALL_RULE) + self.segment = Segment.objects.create(name="Test segment", project=self.project) + segment_rule = SegmentRule.objects.create( + segment=self.segment, type=SegmentRule.ALL_RULE + ) - self.condition_property = 'test_property' - self.condition_value = 'test_value' - Condition.objects.create(property=self.condition_property, value=self.condition_value, - operator=EQUAL, rule=segment_rule) + self.condition_property = "test_property" + self.condition_value = "test_value" + Condition.objects.create( + property=self.condition_property, + value=self.condition_value, + operator=EQUAL, + rule=segment_rule, + ) - self.matching_identity = Identity.objects.create(identifier='user_1', environment=self.environment) - Trait.objects.create(identity=self.matching_identity, trait_key=self.condition_property, value_type=STRING, - string_value=self.condition_value) + self.matching_identity = Identity.objects.create( + identifier="user_1", environment=self.environment + ) + Trait.objects.create( + identity=self.matching_identity, + trait_key=self.condition_property, + value_type=STRING, + string_value=self.condition_value, + ) - self.not_matching_identity = Identity.objects.create(identifier='user_2', environment=self.environment) + self.not_matching_identity = Identity.objects.create( + identifier="user_2", environment=self.environment + ) - def test_feature_segment_save_updates_string_feature_state_value_for_environment(self): + def test_feature_segment_save_updates_string_feature_state_value_for_environment( + self, + ): # Given - overridden_value = 'overridden value' + overridden_value = "overridden value" feature_segment = FeatureSegment( feature=self.remote_config, segment=self.segment, environment=self.environment, value=overridden_value, - value_type=STRING + value_type=STRING, ) # When feature_segment.save() # Then - feature_state = FeatureState.objects.get(feature_segment=feature_segment, environment=self.environment) + feature_state = FeatureState.objects.get( + feature_segment=feature_segment, environment=self.environment + ) assert feature_state.get_feature_state_value() == overridden_value - def test_feature_segment_save_updates_integer_feature_state_value_for_environment(self): + def test_feature_segment_save_updates_integer_feature_state_value_for_environment( + self, + ): # Given overridden_value = 12 feature_segment = FeatureSegment( @@ -192,17 +267,21 @@ def test_feature_segment_save_updates_integer_feature_state_value_for_environmen segment=self.segment, environment=self.environment, value=str(overridden_value), - value_type=INTEGER + value_type=INTEGER, ) # When feature_segment.save() # Then - feature_state = FeatureState.objects.get(feature_segment=feature_segment, environment=self.environment) + feature_state = FeatureState.objects.get( + feature_segment=feature_segment, environment=self.environment + ) assert feature_state.get_feature_state_value() == overridden_value - def test_feature_segment_save_updates_boolean_feature_state_value_for_environment(self): + def test_feature_segment_save_updates_boolean_feature_state_value_for_environment( + self, + ): # Given overridden_value = False feature_segment = FeatureSegment( @@ -210,22 +289,29 @@ def test_feature_segment_save_updates_boolean_feature_state_value_for_environmen segment=self.segment, environment=self.environment, value=str(overridden_value), - value_type=BOOLEAN + value_type=BOOLEAN, ) # When feature_segment.save() # Then - feature_state = FeatureState.objects.get(feature_segment=feature_segment, environment=self.environment) + feature_state = FeatureState.objects.get( + feature_segment=feature_segment, environment=self.environment + ) assert feature_state.get_feature_state_value() == overridden_value def test_feature_state_enabled_value_is_updated_when_feature_segment_updated(self): # Given feature_segment = FeatureSegment.objects.create( - feature=self.remote_config, segment=self.segment, environment=self.environment, priority=1 + feature=self.remote_config, + segment=self.segment, + environment=self.environment, + priority=1, + ) + feature_state = FeatureState.objects.get( + feature_segment=feature_segment, enabled=False ) - feature_state = FeatureState.objects.get(feature_segment=feature_segment, enabled=False) # When feature_segment.enabled = True @@ -238,12 +324,20 @@ def test_feature_state_enabled_value_is_updated_when_feature_segment_updated(sel def test_feature_segment_is_less_than_other_if_priority_lower(self): # Given feature_segment_1 = FeatureSegment.objects.create( - feature=self.remote_config, segment=self.segment, environment=self.environment, priority=1 + feature=self.remote_config, + segment=self.segment, + environment=self.environment, + priority=1, ) - another_segment = Segment.objects.create(name='Another segment', project=self.project) + another_segment = Segment.objects.create( + name="Another segment", project=self.project + ) feature_segment_2 = FeatureSegment.objects.create( - feature=self.remote_config, segment=another_segment, environment=self.environment, priority=2 + feature=self.remote_config, + segment=another_segment, + environment=self.environment, + priority=2, ) # When @@ -256,30 +350,44 @@ def test_feature_segments_are_created_with_correct_priority(self): # Given - 5 feature segments # 2 with the same feature, environment but a different segment - another_segment = Segment.objects.create(name='Another segment', project=self.project) + another_segment = Segment.objects.create( + name="Another segment", project=self.project + ) feature_segment_1 = FeatureSegment.objects.create( - feature=self.remote_config, segment=self.segment, environment=self.environment + feature=self.remote_config, + segment=self.segment, + environment=self.environment, ) feature_segment_2 = FeatureSegment.objects.create( - feature=self.remote_config, segment=another_segment, environment=self.environment + feature=self.remote_config, + segment=another_segment, + environment=self.environment, ) # 1 with the same feature but a different environment - another_environment = Environment.objects.create(name='Another environment', project=self.project) + another_environment = Environment.objects.create( + name="Another environment", project=self.project + ) feature_segment_3 = FeatureSegment.objects.create( - feature=self.remote_config, segment=self.segment, environment=another_environment + feature=self.remote_config, + segment=self.segment, + environment=another_environment, ) # 1 with the same environment but a different feature - another_feature = Feature.objects.create(name='Another feature', project=self.project, type=FLAG) + another_feature = Feature.objects.create( + name="Another feature", project=self.project, type=FLAG + ) feature_segment_4 = FeatureSegment.objects.create( feature=another_feature, segment=self.segment, environment=self.environment ) # 1 with a different feature and a different environment feature_segment_5 = FeatureSegment.objects.create( - feature=another_feature, segment=self.segment, environment=another_environment + feature=another_feature, + segment=self.segment, + environment=another_environment, ) # Then @@ -298,12 +406,20 @@ def test_feature_state_value_for_feature_segments(self): # When feature_segment = FeatureSegment.objects.create( - segment=segment, feature=self.remote_config, environment=self.environment, value="test", value_type=STRING + segment=segment, + feature=self.remote_config, + environment=self.environment, + value="test", + value_type=STRING, ) # Then - feature_state = FeatureState.objects.get(feature=self.remote_config, feature_segment=feature_segment) - assert not FeatureStateValue.objects.filter(feature_state=feature_state).exists() + feature_state = FeatureState.objects.get( + feature=self.remote_config, feature_segment=feature_segment + ) + assert not FeatureStateValue.objects.filter( + feature_state=feature_state + ).exists() # and the feature_state value is correct assert feature_state.get_feature_state_value() == feature_segment.get_value() @@ -312,48 +428,78 @@ def test_feature_state_value_for_feature_segments(self): @pytest.mark.django_db class FeatureStateTest(TestCase): def setUp(self) -> None: - self.organisation = Organisation.objects.create(name='Test org') - self.project = Project.objects.create(name='Test project', organisation=self.organisation) - self.environment = Environment.objects.create(name='Test environment', project=self.project) - self.feature = Feature.objects.create(name='Test feature', project=self.project) + self.organisation = Organisation.objects.create(name="Test org") + self.project = Project.objects.create( + name="Test project", organisation=self.organisation + ) + self.environment = Environment.objects.create( + name="Test environment", project=self.project + ) + self.feature = Feature.objects.create(name="Test feature", project=self.project) @mock.patch("features.models.trigger_feature_state_change_webhooks") - def test_cannot_create_duplicate_feature_state_in_an_environment(self, mock_trigger_webhooks): + def test_cannot_create_duplicate_feature_state_in_an_environment( + self, mock_trigger_webhooks + ): """ Note that although the mock isn't used in this test, it throws an exception on it's thread so we mock it here anyway. """ # Given - duplicate_feature_state = FeatureState(feature=self.feature, environment=self.environment, enabled=True) + duplicate_feature_state = FeatureState( + feature=self.feature, environment=self.environment, enabled=True + ) # When with pytest.raises(ValidationError): duplicate_feature_state.save() # Then - assert FeatureState.objects.filter(feature=self.feature, environment=self.environment).count() == 1 + assert ( + FeatureState.objects.filter( + feature=self.feature, environment=self.environment + ).count() + == 1 + ) def test_feature_state_gt_operator(self): # Given - identity = Identity.objects.create(identifier='test_identity', environment=self.environment) - segment_1 = Segment.objects.create(name='Test Segment 1', project=self.project) - segment_2 = Segment.objects.create(name='Test Segment 2', project=self.project) + identity = Identity.objects.create( + identifier="test_identity", environment=self.environment + ) + segment_1 = Segment.objects.create(name="Test Segment 1", project=self.project) + segment_2 = Segment.objects.create(name="Test Segment 2", project=self.project) feature_segment_p1 = FeatureSegment.objects.create( - segment=segment_1, feature=self.feature, environment=self.environment, priority=1 + segment=segment_1, + feature=self.feature, + environment=self.environment, + priority=1, ) feature_segment_p2 = FeatureSegment.objects.create( - segment=segment_2, feature=self.feature, environment=self.environment, priority=2 + segment=segment_2, + feature=self.feature, + environment=self.environment, + priority=2, ) # When - identity_state = FeatureState.objects.create(identity=identity, feature=self.feature, - environment=self.environment) + identity_state = FeatureState.objects.create( + identity=identity, feature=self.feature, environment=self.environment + ) - segment_1_state = FeatureState.objects.get(feature_segment=feature_segment_p1, feature=self.feature, - environment=self.environment) - segment_2_state = FeatureState.objects.get(feature_segment=feature_segment_p2, feature=self.feature, - environment=self.environment) - default_env_state = FeatureState.objects.get(environment=self.environment, identity=None, feature_segment=None) + segment_1_state = FeatureState.objects.get( + feature_segment=feature_segment_p1, + feature=self.feature, + environment=self.environment, + ) + segment_2_state = FeatureState.objects.get( + feature_segment=feature_segment_p2, + feature=self.feature, + environment=self.environment, + ) + default_env_state = FeatureState.objects.get( + environment=self.environment, identity=None, feature_segment=None + ) # Then - identity state is higher priority than all assert identity_state > segment_1_state @@ -368,11 +514,19 @@ def test_feature_state_gt_operator(self): # and feature state with any segment is greater than default environment state assert segment_2_state > default_env_state - def test_feature_state_gt_operator_throws_value_error_if_different_environments(self): + def test_feature_state_gt_operator_throws_value_error_if_different_environments( + self, + ): # Given - another_environment = Environment.objects.create(name='Another environment', project=self.project) - feature_state_env_1 = FeatureState.objects.filter(environment=self.environment).first() - feature_state_env_2 = FeatureState.objects.filter(environment=another_environment).first() + another_environment = Environment.objects.create( + name="Another environment", project=self.project + ) + feature_state_env_1 = FeatureState.objects.filter( + environment=self.environment + ).first() + feature_state_env_2 = FeatureState.objects.filter( + environment=another_environment + ).first() # When with pytest.raises(ValueError): @@ -382,9 +536,13 @@ def test_feature_state_gt_operator_throws_value_error_if_different_environments( def test_feature_state_gt_operator_throws_value_error_if_different_features(self): # Given - another_feature = Feature.objects.create(name='Another feature', project=self.project) + another_feature = Feature.objects.create( + name="Another feature", project=self.project + ) feature_state_env_1 = FeatureState.objects.filter(feature=self.feature).first() - feature_state_env_2 = FeatureState.objects.filter(feature=another_feature).first() + feature_state_env_2 = FeatureState.objects.filter( + feature=another_feature + ).first() # When with pytest.raises(ValueError): @@ -394,13 +552,19 @@ def test_feature_state_gt_operator_throws_value_error_if_different_features(self def test_feature_state_gt_operator_throws_value_error_if_different_identities(self): # Given - identity_1 = Identity.objects.create(identifier="identity_1", environment=self.environment) - identity_2 = Identity.objects.create(identifier="identity_2", environment=self.environment) + identity_1 = Identity.objects.create( + identifier="identity_1", environment=self.environment + ) + identity_2 = Identity.objects.create( + identifier="identity_2", environment=self.environment + ) - feature_state_identity_1 = FeatureState.objects.create(feature=self.feature, environment=self.environment, - identity=identity_1) - feature_state_identity_2 = FeatureState.objects.create(feature=self.feature, environment=self.environment, - identity=identity_2) + feature_state_identity_1 = FeatureState.objects.create( + feature=self.feature, environment=self.environment, identity=identity_1 + ) + feature_state_identity_2 = FeatureState.objects.create( + feature=self.feature, environment=self.environment, identity=identity_2 + ) # When with pytest.raises(ValueError): @@ -411,11 +575,12 @@ def test_feature_state_gt_operator_throws_value_error_if_different_identities(se @mock.patch("features.models.trigger_feature_state_change_webhooks") def test_save_calls_trigger_webhooks(self, mock_trigger_webhooks): # Given - feature_state = FeatureState.objects.get(feature=self.feature, environment=self.environment) + feature_state = FeatureState.objects.get( + feature=self.feature, environment=self.environment + ) # When feature_state.save() # Then mock_trigger_webhooks.assert_called_with(feature_state) - diff --git a/src/features/tests/test_permissions.py b/src/features/tests/test_permissions.py index f9077a067bb2..bc28f3062322 100644 --- a/src/features/tests/test_permissions.py +++ b/src/features/tests/test_permissions.py @@ -5,7 +5,11 @@ from features.models import Feature from features.permissions import FeaturePermissions from organisations.models import Organisation, OrganisationRole -from projects.models import Project, UserProjectPermission, UserPermissionGroupProjectPermission +from projects.models import ( + Project, + UserPermissionGroupProjectPermission, + UserProjectPermission, +) from users.models import FFAdminUser, UserPermissionGroup mock_view = mock.MagicMock() @@ -17,18 +21,22 @@ @pytest.mark.django_db class FeaturePermissionsTestCase(TestCase): def setUp(self) -> None: - self.organisation = Organisation.objects.create(name='Test') - self.project = Project.objects.create(name='Test project', organisation=self.organisation) + self.organisation = Organisation.objects.create(name="Test") + self.project = Project.objects.create( + name="Test project", organisation=self.organisation + ) - self.feature = Feature.objects.create(name='Test feature', project=self.project) + self.feature = Feature.objects.create(name="Test feature", project=self.project) - self.user = FFAdminUser.objects.create(email='user@test.com') + self.user = FFAdminUser.objects.create(email="user@test.com") self.user.add_organisation(self.organisation, OrganisationRole.USER) - self.org_admin = FFAdminUser.objects.create(email='admin@test.com') + self.org_admin = FFAdminUser.objects.create(email="admin@test.com") self.org_admin.add_organisation(self.organisation, OrganisationRole.ADMIN) - self.group = UserPermissionGroup.objects.create(name='Test group', organisation=self.organisation) + self.group = UserPermissionGroup.objects.create( + name="Test group", organisation=self.organisation + ) self.group.users.add(self.user) mock_view.kwargs = {} @@ -36,9 +44,9 @@ def setUp(self) -> None: def test_organisation_admin_can_list_features(self): # Given - mock_view.action = 'list' + mock_view.action = "list" mock_view.detail = False - mock_view.kwargs['project_pk'] = self.project.id + mock_view.kwargs["project_pk"] = self.project.id mock_request.user = self.org_admin # When @@ -49,11 +57,13 @@ def test_organisation_admin_can_list_features(self): def test_project_admin_can_list_features(self): # Given - UserProjectPermission.objects.create(user=self.user, admin=True, project=self.project) + UserProjectPermission.objects.create( + user=self.user, admin=True, project=self.project + ) - mock_view.action = 'list' + mock_view.action = "list" mock_view.detail = False - mock_view.kwargs['project_pk'] = self.project.id + mock_view.kwargs["project_pk"] = self.project.id mock_request.user = self.user # When @@ -64,13 +74,14 @@ def test_project_admin_can_list_features(self): def test_project_user_with_read_access_can_list_features(self): # Given - user_project_permission = UserProjectPermission.objects.create(user=self.user, admin=False, - project=self.project) - user_project_permission.set_permissions(['VIEW_PROJECT']) + user_project_permission = UserProjectPermission.objects.create( + user=self.user, admin=False, project=self.project + ) + user_project_permission.set_permissions(["VIEW_PROJECT"]) - mock_view.action = 'list' + mock_view.action = "list" mock_view.detail = False - mock_view.kwargs['project_pk'] = self.project.id + mock_view.kwargs["project_pk"] = self.project.id mock_request.user = self.user # When @@ -81,9 +92,9 @@ def test_project_user_with_read_access_can_list_features(self): def test_user_with_no_project_permissions_cannot_list_features(self): # Given - mock_view.action = 'list' + mock_view.action = "list" mock_view.detail = False - mock_view.kwargs['project_pk'] = self.project.id + mock_view.kwargs["project_pk"] = self.project.id mock_request.user = self.user # When @@ -94,13 +105,10 @@ def test_user_with_no_project_permissions_cannot_list_features(self): def test_organisation_admin_can_create_feature(self): # Given - mock_view.action = 'create' + mock_view.action = "create" mock_view.detail = False mock_request.user = self.org_admin - mock_request.data = { - 'project': self.project.id, - 'name': 'new feature' - } + mock_request.data = {"project": self.project.id, "name": "new feature"} # When result = feature_permissions.has_permission(mock_request, mock_view) @@ -111,14 +119,13 @@ def test_organisation_admin_can_create_feature(self): def test_project_admin_can_create_feature(self): # Given # use a group to test groups work too - UserPermissionGroupProjectPermission.objects.create(group=self.group, project=self.project, admin=True) - mock_view.action = 'create' + UserPermissionGroupProjectPermission.objects.create( + group=self.group, project=self.project, admin=True + ) + mock_view.action = "create" mock_view.detail = False mock_request.user = self.user - mock_request.data = { - 'project': self.project.id, - 'name': 'new feature' - } + mock_request.data = {"project": self.project.id, "name": "new feature"} # When result = feature_permissions.has_permission(mock_request, mock_view) @@ -129,16 +136,14 @@ def test_project_admin_can_create_feature(self): def test_project_user_with_create_feature_permission_can_create_feature(self): # Given # use a group to test groups work too - user_group_permission = UserPermissionGroupProjectPermission.objects.create(group=self.group, - project=self.project, admin=False) - user_group_permission.add_permission('CREATE_FEATURE') - mock_view.action = 'create' + user_group_permission = UserPermissionGroupProjectPermission.objects.create( + group=self.group, project=self.project, admin=False + ) + user_group_permission.add_permission("CREATE_FEATURE") + mock_view.action = "create" mock_view.detail = False mock_request.user = self.user - mock_request.data = { - 'project': self.project.id, - 'name': 'new feature' - } + mock_request.data = {"project": self.project.id, "name": "new feature"} # When result = feature_permissions.has_permission(mock_request, mock_view) @@ -148,13 +153,10 @@ def test_project_user_with_create_feature_permission_can_create_feature(self): def test_project_user_without_create_feature_permission_cannot_create_feature(self): # Given - mock_view.action = 'create' + mock_view.action = "create" mock_view.detail = False mock_request.user = self.user - mock_request.data = { - 'project': self.project.id, - 'name': 'new feature' - } + mock_request.data = {"project": self.project.id, "name": "new feature"} # When result = feature_permissions.has_permission(mock_request, mock_view) @@ -164,39 +166,49 @@ def test_project_user_without_create_feature_permission_cannot_create_feature(se def test_organisation_admin_can_view_feature(self): # Given - mock_view.action = 'retrieve' + mock_view.action = "retrieve" mock_view.detail = True mock_request.user = self.org_admin # When - result = feature_permissions.has_object_permission(mock_request, mock_view, self.feature) + result = feature_permissions.has_object_permission( + mock_request, mock_view, self.feature + ) # Then assert result def test_project_admin_can_view_feature(self): # Given - UserProjectPermission.objects.create(user=self.user, project=self.project, admin=True) + UserProjectPermission.objects.create( + user=self.user, project=self.project, admin=True + ) mock_request.user = self.user - mock_view.action = 'retrieve' + mock_view.action = "retrieve" mock_view.detail = True # When - result = feature_permissions.has_object_permission(mock_request, mock_view, self.feature) + result = feature_permissions.has_object_permission( + mock_request, mock_view, self.feature + ) # Then assert result def test_project_user_with_view_project_permission_can_view_feature(self): # Given - user_permission = UserProjectPermission.objects.create(user=self.user, project=self.project, admin=False) - user_permission.set_permissions(['VIEW_PROJECT']) + user_permission = UserProjectPermission.objects.create( + user=self.user, project=self.project, admin=False + ) + user_permission.set_permissions(["VIEW_PROJECT"]) mock_request.user = self.user - mock_view.action = 'retrieve' + mock_view.action = "retrieve" mock_view.detail = True # When - result = feature_permissions.has_object_permission(mock_request, mock_view, self.feature) + result = feature_permissions.has_object_permission( + mock_request, mock_view, self.feature + ) # Then assert result @@ -204,137 +216,167 @@ def test_project_user_with_view_project_permission_can_view_feature(self): def test_project_user_without_view_project_permission_cannot_view_feature(self): # Given mock_request.user = self.user - mock_view.action = 'retrieve' + mock_view.action = "retrieve" mock_view.detail = True # When - result = feature_permissions.has_object_permission(mock_request, mock_view, self.feature) + result = feature_permissions.has_object_permission( + mock_request, mock_view, self.feature + ) # Then assert not result def test_organisation_admin_can_edit_feature(self): # Given - mock_view.action = 'update' + mock_view.action = "update" mock_view.detail = True mock_request.user = self.org_admin # When - result = feature_permissions.has_object_permission(mock_request, mock_view, self.feature) + result = feature_permissions.has_object_permission( + mock_request, mock_view, self.feature + ) # Then assert result def test_project_admin_can_edit_feature(self): # Given - UserProjectPermission.objects.create(user=self.user, project=self.project, admin=True) - mock_view.action = 'update' + UserProjectPermission.objects.create( + user=self.user, project=self.project, admin=True + ) + mock_view.action = "update" mock_view.detail = True mock_request.user = self.user # When - result = feature_permissions.has_object_permission(mock_request, mock_view, self.feature) + result = feature_permissions.has_object_permission( + mock_request, mock_view, self.feature + ) # Then assert result def test_project_user_cannot_edit_feature(self): # Given - mock_view.action = 'update' + mock_view.action = "update" mock_view.detail = True mock_request.user = self.user # When - result = feature_permissions.has_object_permission(mock_request, mock_view, self.feature) + result = feature_permissions.has_object_permission( + mock_request, mock_view, self.feature + ) # Then assert not result def test_organisation_admin_can_delete_feature(self): # Given - mock_view.action = 'destroy' + mock_view.action = "destroy" mock_view.detail = True mock_request.user = self.org_admin # When - result = feature_permissions.has_object_permission(mock_request, mock_view, self.feature) + result = feature_permissions.has_object_permission( + mock_request, mock_view, self.feature + ) # Then assert result def test_project_admin_can_delete_feature(self): # Given - UserProjectPermission.objects.create(user=self.user, project=self.project, admin=True) - mock_view.action = 'destroy' + UserProjectPermission.objects.create( + user=self.user, project=self.project, admin=True + ) + mock_view.action = "destroy" mock_view.detail = True mock_request.user = self.user # When - result = feature_permissions.has_object_permission(mock_request, mock_view, self.feature) + result = feature_permissions.has_object_permission( + mock_request, mock_view, self.feature + ) # Then assert result def test_project_user_with_delete_feature_permission_can_delete_feature(self): # Given - user_project_permission = UserProjectPermission.objects.create(user=self.user, project=self.project) - user_project_permission.add_permission('DELETE_FEATURE') + user_project_permission = UserProjectPermission.objects.create( + user=self.user, project=self.project + ) + user_project_permission.add_permission("DELETE_FEATURE") - mock_view.action = 'destroy' + mock_view.action = "destroy" mock_view.detail = True mock_request.user = self.user # When - result = feature_permissions.has_object_permission(mock_request, mock_view, self.feature) + result = feature_permissions.has_object_permission( + mock_request, mock_view, self.feature + ) # Then assert result def test_project_user_without_delete_feature_permission_cannot_delete_feature(self): # Given - mock_view.action = 'destroy' + mock_view.action = "destroy" mock_view.detail = True mock_request.user = self.user # When - result = feature_permissions.has_object_permission(mock_request, mock_view, self.feature) + result = feature_permissions.has_object_permission( + mock_request, mock_view, self.feature + ) # Then assert not result def test_organisation_admin_can_update_feature_segments(self): # Given - mock_view.action = 'segments' + mock_view.action = "segments" mock_view.detail = True mock_request.user = self.org_admin # When - result = feature_permissions.has_object_permission(mock_request, mock_view, self.feature) + result = feature_permissions.has_object_permission( + mock_request, mock_view, self.feature + ) # Then assert result def test_project_admin_can_update_feature_segments(self): # Given - UserProjectPermission.objects.create(user=self.user, project=self.project, admin=True) - mock_view.action = 'segments' + UserProjectPermission.objects.create( + user=self.user, project=self.project, admin=True + ) + mock_view.action = "segments" mock_view.detail = True mock_request.user = self.user # When - result = feature_permissions.has_object_permission(mock_request, mock_view, self.feature) + result = feature_permissions.has_object_permission( + mock_request, mock_view, self.feature + ) # Then assert result def test_project_user_cannot_update_feature_segments(self): # Given - mock_view.action = 'segments' + mock_view.action = "segments" mock_view.detail = True mock_request.user = self.user # When - result = feature_permissions.has_object_permission(mock_request, mock_view, self.feature) + result = feature_permissions.has_object_permission( + mock_request, mock_view, self.feature + ) # Then assert not result diff --git a/src/features/tests/test_tasks.py b/src/features/tests/test_tasks.py index 7b4a547a107b..2f918db4e3f0 100644 --- a/src/features/tests/test_tasks.py +++ b/src/features/tests/test_tasks.py @@ -4,7 +4,7 @@ import pytest from environments.models import Environment -from features.models import Feature, FeatureState, CONFIG, FeatureStateValue +from features.models import CONFIG, Feature, FeatureState, FeatureStateValue from features.tasks import trigger_feature_state_change_webhooks from organisations.models import Organisation from projects.models import Project @@ -18,10 +18,12 @@ def test_trigger_feature_state_change_webhooks(MockThread): initial_value = "initial" new_value = "new" - organisation = Organisation.objects.create(name='Test organisation') - project = Project.objects.create(name='Test project', organisation=organisation) - environment = Environment.objects.create(name='Test environment', project=project) - feature = Feature.objects.create(name='Test feature', project=project, initial_value=initial_value, type=CONFIG) + organisation = Organisation.objects.create(name="Test organisation") + project = Project.objects.create(name="Test project", organisation=organisation) + environment = Environment.objects.create(name="Test environment", project=project) + feature = Feature.objects.create( + name="Test feature", project=project, initial_value=initial_value, type=CONFIG + ) feature_state = FeatureState.objects.get(feature=feature, environment=environment) # update the feature state value and save both objects to ensure that the history is updated @@ -41,7 +43,10 @@ def test_trigger_feature_state_change_webhooks(MockThread): organisation_webhook_call_args = call_list[1] # verify that the data for both calls is the same - assert environment_webhook_call_args[1]["args"][1] == organisation_webhook_call_args[1]["args"][1] + assert ( + environment_webhook_call_args[1]["args"][1] + == organisation_webhook_call_args[1]["args"][1] + ) data = environment_webhook_call_args[1]["args"][1] assert data["new_state"]["feature_state_value"] == new_value diff --git a/src/features/tests/test_utils.py b/src/features/tests/test_utils.py index 03e8239b401d..52a25836681a 100644 --- a/src/features/tests/test_utils.py +++ b/src/features/tests/test_utils.py @@ -1,16 +1,19 @@ import pytest -from features.utils import get_value_type, INTEGER, STRING, BOOLEAN +from features.utils import BOOLEAN, INTEGER, STRING, get_value_type -@pytest.mark.parametrize("value, expected_type", ( +@pytest.mark.parametrize( + "value, expected_type", + ( ("1", INTEGER), ("a string", STRING), ("True", BOOLEAN), ("true", BOOLEAN), ("False", BOOLEAN), ("false", BOOLEAN), - ("{\"some_other\": \"data_type\"}", STRING) -)) + ('{"some_other": "data_type"}', STRING), + ), +) def test_get_value_type(value, expected_type): assert get_value_type(value) == expected_type diff --git a/src/features/tests/test_views.py b/src/features/tests/test_views.py index a37a4824d81d..597cceebc679 100644 --- a/src/features/tests/test_views.py +++ b/src/features/tests/test_views.py @@ -6,18 +6,28 @@ from rest_framework import status from rest_framework.test import APIClient, APITestCase -from audit.models import AuditLog, RelatedObjectType, IDENTITY_FEATURE_STATE_UPDATED_MESSAGE, \ - IDENTITY_FEATURE_STATE_DELETED_MESSAGE -from environments.models import Environment +from audit.models import ( + IDENTITY_FEATURE_STATE_DELETED_MESSAGE, + IDENTITY_FEATURE_STATE_UPDATED_MESSAGE, + AuditLog, + RelatedObjectType, +) from environments.identities.models import Identity -from features.models import Feature, FeatureState, FeatureSegment, CONFIG, FeatureStateValue -from features.utils import INTEGER, BOOLEAN, STRING +from environments.models import Environment +from features.models import ( + CONFIG, + Feature, + FeatureSegment, + FeatureState, + FeatureStateValue, +) +from features.utils import BOOLEAN, INTEGER, STRING from organisations.models import Organisation, OrganisationRole from projects.models import Project +from projects.tags.models import Tag from segments.models import Segment from users.models import FFAdminUser from util.tests import Helper -from projects.tags.models import Tag # patch this function as it's triggering extra threads and causing errors mock.patch("features.models.trigger_feature_state_change_webhooks").start() @@ -25,8 +35,8 @@ @pytest.mark.django_db class ProjectFeatureTestCase(TestCase): - project_features_url = '/api/v1/projects/%s/features/' - project_feature_detail_url = '/api/v1/projects/%s/features/%d/' + project_features_url = "/api/v1/projects/%s/features/" + project_feature_detail_url = "/api/v1/projects/%s/features/%d/" post_template = '{ "name": "%s", "project": %d, "initial_value": "%s" }' def setUp(self): @@ -34,59 +44,80 @@ def setUp(self): user = Helper.create_ffadminuser() self.client.force_authenticate(user=user) - self.organisation = Organisation.objects.create(name='Test Org') + self.organisation = Organisation.objects.create(name="Test Org") user.add_organisation(self.organisation, OrganisationRole.ADMIN) - self.project = Project.objects.create(name='Test project', organisation=self.organisation) - self.project2 = Project.objects.create(name='Test project2', organisation=self.organisation) - self.environment_1 = Environment.objects.create(name='Test environment 1', project=self.project) - self.environment_2 = Environment.objects.create(name='Test environment 2', project=self.project) - - self.tag_one = Tag.objects.create(label='Test Tag', - color='#fffff', - description='Test Tag description', - project=self.project) - self.tag_two = Tag.objects.create(label='Test Tag2', - color='#fffff', - description='Test Tag2 description', - project=self.project) - self.tag_other_project = Tag.objects.create(label='Wrong Tag', - color='#fffff', - description='Test Tag description', - project=self.project2) + self.project = Project.objects.create( + name="Test project", organisation=self.organisation + ) + self.project2 = Project.objects.create( + name="Test project2", organisation=self.organisation + ) + self.environment_1 = Environment.objects.create( + name="Test environment 1", project=self.project + ) + self.environment_2 = Environment.objects.create( + name="Test environment 2", project=self.project + ) + + self.tag_one = Tag.objects.create( + label="Test Tag", + color="#fffff", + description="Test Tag description", + project=self.project, + ) + self.tag_two = Tag.objects.create( + label="Test Tag2", + color="#fffff", + description="Test Tag2 description", + project=self.project, + ) + self.tag_other_project = Tag.objects.create( + label="Wrong Tag", + color="#fffff", + description="Test Tag description", + project=self.project2, + ) def test_should_create_feature_states_when_feature_created(self): # Given - set up data - default_value = 'This is a value' + default_value = "This is a value" data = { "name": "test feature", "initial_value": default_value, "type": CONFIG, "project": self.project.id, } - url = reverse('api-v1:projects:project-features-list', args=[self.project.id]) + url = reverse("api-v1:projects:project-features-list", args=[self.project.id]) # When - response = self.client.post(url, data=json.dumps(data), content_type='application/json') + response = self.client.post( + url, data=json.dumps(data), content_type="application/json" + ) # Then assert response.status_code == status.HTTP_201_CREATED # check feature was created successfully - assert Feature.objects.filter(name="test feature", project=self.project.id).count() == 1 + assert ( + Feature.objects.filter(name="test feature", project=self.project.id).count() + == 1 + ) # check feature was added to environment assert FeatureState.objects.filter(environment=self.environment_1).count() == 1 assert FeatureState.objects.filter(environment=self.environment_2).count() == 1 # check that value was correctly added to feature state - feature_state = FeatureState.objects.filter(environment=self.environment_1).first() + feature_state = FeatureState.objects.filter( + environment=self.environment_1 + ).first() assert feature_state.get_feature_state_value() == default_value def test_should_create_feature_states_with_integer_value_when_feature_created(self): # Given - set up data default_value = 12 - url = reverse('api-v1:projects:project-features-list', args=[self.project.id]) + url = reverse("api-v1:projects:project-features-list", args=[self.project.id]) data = { "name": "test feature", "type": CONFIG, @@ -95,48 +126,62 @@ def test_should_create_feature_states_with_integer_value_when_feature_created(se } # When - response = self.client.post(url, data=json.dumps(data), content_type='application/json') + response = self.client.post( + url, data=json.dumps(data), content_type="application/json" + ) # Then assert response.status_code == status.HTTP_201_CREATED # check feature was created successfully - assert Feature.objects.filter(name="test feature", project=self.project.id).count() == 1 + assert ( + Feature.objects.filter(name="test feature", project=self.project.id).count() + == 1 + ) # check feature was added to environment assert FeatureState.objects.filter(environment=self.environment_1).count() == 1 assert FeatureState.objects.filter(environment=self.environment_2).count() == 1 # check that value was correctly added to feature state - feature_state = FeatureState.objects.filter(environment=self.environment_1).first() + feature_state = FeatureState.objects.filter( + environment=self.environment_1 + ).first() assert feature_state.get_feature_state_value() == default_value def test_should_create_feature_states_with_boolean_value_when_feature_created(self): # Given - set up data default_value = True - feature_name = 'Test feature' + feature_name = "Test feature" data = { - 'name': 'Test feature', - 'initial_value': default_value, - 'type': CONFIG, - 'project': self.project.id, + "name": "Test feature", + "initial_value": default_value, + "type": CONFIG, + "project": self.project.id, } - url = reverse('api-v1:projects:project-features-list', args=[self.project.id]) + url = reverse("api-v1:projects:project-features-list", args=[self.project.id]) # When - response = self.client.post(url, data=json.dumps(data), content_type='application/json') + response = self.client.post( + url, data=json.dumps(data), content_type="application/json" + ) # Then assert response.status_code == status.HTTP_201_CREATED # check feature was created successfully - assert Feature.objects.filter(name=feature_name, project=self.project.id).count() == 1 + assert ( + Feature.objects.filter(name=feature_name, project=self.project.id).count() + == 1 + ) # check feature was added to environment assert FeatureState.objects.filter(environment=self.environment_1).count() == 1 assert FeatureState.objects.filter(environment=self.environment_2).count() == 1 # check that value was correctly added to feature state - feature_state = FeatureState.objects.filter(environment=self.environment_1).first() + feature_state = FeatureState.objects.filter( + environment=self.environment_1 + ).first() assert feature_state.get_feature_state_value() == default_value def test_should_delete_feature_states_when_feature_deleted(self): @@ -144,135 +189,209 @@ def test_should_delete_feature_states_when_feature_deleted(self): feature = Feature.objects.create(name="test feature", project=self.project) # When - response = self.client.delete(self.project_feature_detail_url % (self.project.id, feature.id)) + response = self.client.delete( + self.project_feature_detail_url % (self.project.id, feature.id) + ) # Then assert response.status_code == status.HTTP_204_NO_CONTENT # check feature was deleted successfully - assert Feature.objects.filter(name="test feature", project=self.project.id).count() == 0 + assert ( + Feature.objects.filter(name="test feature", project=self.project.id).count() + == 0 + ) # check feature was removed from all environments - assert FeatureState.objects.filter(environment=self.environment_1, feature=feature).count() == 0 - assert FeatureState.objects.filter(environment=self.environment_2, feature=feature).count() == 0 + assert ( + FeatureState.objects.filter( + environment=self.environment_1, feature=feature + ).count() + == 0 + ) + assert ( + FeatureState.objects.filter( + environment=self.environment_2, feature=feature + ).count() + == 0 + ) def test_audit_log_created_when_feature_created(self): # Given - url = reverse('api-v1:projects:project-features-list', args=[self.project.id]) - data = { - 'name': 'Test feature flag', - 'type': 'FLAG', - 'project': self.project.id - } + url = reverse("api-v1:projects:project-features-list", args=[self.project.id]) + data = {"name": "Test feature flag", "type": "FLAG", "project": self.project.id} # When self.client.post(url, data=data) # Then - assert AuditLog.objects.filter(related_object_type=RelatedObjectType.FEATURE.name).count() == 1 + assert ( + AuditLog.objects.filter( + related_object_type=RelatedObjectType.FEATURE.name + ).count() + == 1 + ) def test_audit_log_created_when_feature_updated(self): # Given - feature = Feature.objects.create(name='Test Feature', project=self.project) - url = reverse('api-v1:projects:project-features-detail', args=[self.project.id, feature.id]) + feature = Feature.objects.create(name="Test Feature", project=self.project) + url = reverse( + "api-v1:projects:project-features-detail", + args=[self.project.id, feature.id], + ) data = { - 'name': 'Test Feature updated', - 'type': 'FLAG', - 'project': self.project.id + "name": "Test Feature updated", + "type": "FLAG", + "project": self.project.id, } # When self.client.put(url, data=data) # Then - assert AuditLog.objects.filter(related_object_type=RelatedObjectType.FEATURE.name).count() == 1 + assert ( + AuditLog.objects.filter( + related_object_type=RelatedObjectType.FEATURE.name + ).count() + == 1 + ) def test_audit_log_created_when_feature_state_created_for_identity(self): # Given - feature = Feature.objects.create(name='Test feature', project=self.project) - identity = Identity.objects.create(identifier='test-identifier', environment=self.environment_1) - url = reverse('api-v1:environments:identity-featurestates-list', args=[self.environment_1.api_key, - identity.id]) - data = { - "feature": feature.id, - "enabled": True - } + feature = Feature.objects.create(name="Test feature", project=self.project) + identity = Identity.objects.create( + identifier="test-identifier", environment=self.environment_1 + ) + url = reverse( + "api-v1:environments:identity-featurestates-list", + args=[self.environment_1.api_key, identity.id], + ) + data = {"feature": feature.id, "enabled": True} # When - self.client.post(url, data=json.dumps(data), content_type='application/json') + self.client.post(url, data=json.dumps(data), content_type="application/json") # Then - assert AuditLog.objects.filter(related_object_type=RelatedObjectType.FEATURE_STATE.name).count() == 1 + assert ( + AuditLog.objects.filter( + related_object_type=RelatedObjectType.FEATURE_STATE.name + ).count() + == 1 + ) # and - expected_log_message = IDENTITY_FEATURE_STATE_UPDATED_MESSAGE % (feature.name, identity.identifier) - audit_log = AuditLog.objects.get(related_object_type=RelatedObjectType.FEATURE_STATE.name) + expected_log_message = IDENTITY_FEATURE_STATE_UPDATED_MESSAGE % ( + feature.name, + identity.identifier, + ) + audit_log = AuditLog.objects.get( + related_object_type=RelatedObjectType.FEATURE_STATE.name + ) assert audit_log.log == expected_log_message def test_audit_log_created_when_feature_state_updated_for_identity(self): # Given - feature = Feature.objects.create(name='Test feature', project=self.project) - identity = Identity.objects.create(identifier='test-identifier', environment=self.environment_1) - feature_state = FeatureState.objects.create(feature=feature, environment=self.environment_1, identity=identity, - enabled=True) - url = reverse('api-v1:environments:identity-featurestates-detail', args=[self.environment_1.api_key, - identity.id, feature_state.id]) - data = { - "feature": feature.id, - "enabled": False - } + feature = Feature.objects.create(name="Test feature", project=self.project) + identity = Identity.objects.create( + identifier="test-identifier", environment=self.environment_1 + ) + feature_state = FeatureState.objects.create( + feature=feature, + environment=self.environment_1, + identity=identity, + enabled=True, + ) + url = reverse( + "api-v1:environments:identity-featurestates-detail", + args=[self.environment_1.api_key, identity.id, feature_state.id], + ) + data = {"feature": feature.id, "enabled": False} # When - res = self.client.put(url, data=json.dumps(data), content_type='application/json') + res = self.client.put( + url, data=json.dumps(data), content_type="application/json" + ) # Then - assert AuditLog.objects.filter(related_object_type=RelatedObjectType.FEATURE_STATE.name).count() == 1 + assert ( + AuditLog.objects.filter( + related_object_type=RelatedObjectType.FEATURE_STATE.name + ).count() + == 1 + ) # and - expected_log_message = IDENTITY_FEATURE_STATE_UPDATED_MESSAGE % (feature.name, identity.identifier) - audit_log = AuditLog.objects.get(related_object_type=RelatedObjectType.FEATURE_STATE.name) + expected_log_message = IDENTITY_FEATURE_STATE_UPDATED_MESSAGE % ( + feature.name, + identity.identifier, + ) + audit_log = AuditLog.objects.get( + related_object_type=RelatedObjectType.FEATURE_STATE.name + ) assert audit_log.log == expected_log_message def test_audit_log_created_when_feature_state_deleted_for_identity(self): # Given - feature = Feature.objects.create(name='Test feature', project=self.project) - identity = Identity.objects.create(identifier='test-identifier', environment=self.environment_1) - feature_state = FeatureState.objects.create(feature=feature, environment=self.environment_1, identity=identity, - enabled=True) - url = reverse('api-v1:environments:identity-featurestates-detail', args=[self.environment_1.api_key, - identity.id, feature_state.id]) + feature = Feature.objects.create(name="Test feature", project=self.project) + identity = Identity.objects.create( + identifier="test-identifier", environment=self.environment_1 + ) + feature_state = FeatureState.objects.create( + feature=feature, + environment=self.environment_1, + identity=identity, + enabled=True, + ) + url = reverse( + "api-v1:environments:identity-featurestates-detail", + args=[self.environment_1.api_key, identity.id, feature_state.id], + ) # When res = self.client.delete(url) # Then - assert AuditLog.objects.filter(related_object_type=RelatedObjectType.FEATURE_STATE.name).count() == 1 + assert ( + AuditLog.objects.filter( + related_object_type=RelatedObjectType.FEATURE_STATE.name + ).count() + == 1 + ) # and - expected_log_message = IDENTITY_FEATURE_STATE_DELETED_MESSAGE % (feature.name, identity.identifier) - audit_log = AuditLog.objects.get(related_object_type=RelatedObjectType.FEATURE_STATE.name) + expected_log_message = IDENTITY_FEATURE_STATE_DELETED_MESSAGE % ( + feature.name, + identity.identifier, + ) + audit_log = AuditLog.objects.get( + related_object_type=RelatedObjectType.FEATURE_STATE.name + ) assert audit_log.log == expected_log_message def test_should_create_tags_when_feature_created(self): # Given - set up data default_value = "Test" - feature_name = 'Test feature' + feature_name = "Test feature" data = { - 'name': feature_name, - 'project': self.project.id, - 'initial_value': default_value, - 'tags': [self.tag_one.id, self.tag_two.id] + "name": feature_name, + "project": self.project.id, + "initial_value": default_value, + "tags": [self.tag_one.id, self.tag_two.id], } # When - response = self.client.post(self.project_features_url % self.project.id, - data=json.dumps(data), - content_type='application/json') + response = self.client.post( + self.project_features_url % self.project.id, + data=json.dumps(data), + content_type="application/json", + ) # Then assert response.status_code == status.HTTP_201_CREATED # check feature was created successfully - feature = Feature.objects.filter(name=feature_name, project=self.project.id).first() + feature = Feature.objects.filter( + name=feature_name, project=self.project.id + ).first() # check tags where added assert feature.tags.count() == 2 @@ -282,42 +401,51 @@ def test_when_add_tags_from_different_project_on_feature_create_then_failed(self # Given - set up data feature_name = "test feature" data = { - 'name': feature_name, - 'project': self.project.id, - 'initial_value': 'test', - 'tags': [self.tag_other_project.id] + "name": feature_name, + "project": self.project.id, + "initial_value": "test", + "tags": [self.tag_other_project.id], } # When - response = self.client.post(self.project_features_url % self.project.id, - data=json.dumps(data), - content_type='application/json') + response = self.client.post( + self.project_features_url % self.project.id, + data=json.dumps(data), + content_type="application/json", + ) # Then assert response.status_code == status.HTTP_400_BAD_REQUEST # check no feature was created successfully - assert Feature.objects.filter(name=feature_name, project=self.project.id).count() == 0 + assert ( + Feature.objects.filter(name=feature_name, project=self.project.id).count() + == 0 + ) def test_when_add_tags_on_feature_update_then_success(self): # Given - set up data feature = Feature.objects.create(project=self.project, name="test feature") data = { - 'name': feature.name, - 'project': self.project.id, - 'tags': [self.tag_one.id] + "name": feature.name, + "project": self.project.id, + "tags": [self.tag_one.id], } # When - response = self.client.put(self.project_feature_detail_url % (self.project.id, feature.id), - data=json.dumps(data), - content_type='application/json') + response = self.client.put( + self.project_feature_detail_url % (self.project.id, feature.id), + data=json.dumps(data), + content_type="application/json", + ) # Then assert response.status_code == status.HTTP_200_OK # check feature was created successfully - check_feature = Feature.objects.filter(name=feature.name, project=self.project.id).first() + check_feature = Feature.objects.filter( + name=feature.name, project=self.project.id + ).first() # check tags added assert check_feature.tags.count() == 1 @@ -326,21 +454,25 @@ def test_when_add_tags_from_different_project_on_feature_update_then_failed(self # Given - set up data feature = Feature.objects.create(project=self.project, name="test feature") data = { - 'name': feature.name, - 'project': self.project.id, - 'tags': [self.tag_other_project.id] + "name": feature.name, + "project": self.project.id, + "tags": [self.tag_other_project.id], } # When - response = self.client.put(self.project_feature_detail_url % (self.project.id, feature.id), - data=json.dumps(data), - content_type='application/json') + response = self.client.put( + self.project_feature_detail_url % (self.project.id, feature.id), + data=json.dumps(data), + content_type="application/json", + ) # Then assert response.status_code == status.HTTP_400_BAD_REQUEST # check feature was created successfully - check_feature = Feature.objects.filter(name=feature.name, project=self.project.id).first() + check_feature = Feature.objects.filter( + name=feature.name, project=self.project.id + ).first() # check tags not added assert check_feature.tags.count() == 0 @@ -370,33 +502,55 @@ def setUp(self) -> None: user = Helper.create_ffadminuser() self.client.force_authenticate(user=user) - organisation = Organisation.objects.create(name='Test Org') + organisation = Organisation.objects.create(name="Test Org") user.add_organisation(organisation, OrganisationRole.ADMIN) - self.project = Project.objects.create(organisation=organisation, name='Test project') - self.environment_1 = Environment.objects.create(project=self.project, name='Test environment 1') - self.environment_2 = Environment.objects.create(project=self.project, name='Test environment 2') - self.feature = Feature.objects.create(project=self.project, name='Test feature') - self.segment = Segment.objects.create(project=self.project, name='Test segment') + self.project = Project.objects.create( + organisation=organisation, name="Test project" + ) + self.environment_1 = Environment.objects.create( + project=self.project, name="Test environment 1" + ) + self.environment_2 = Environment.objects.create( + project=self.project, name="Test environment 2" + ) + self.feature = Feature.objects.create(project=self.project, name="Test feature") + self.segment = Segment.objects.create(project=self.project, name="Test segment") def test_list_feature_segments(self): # Given - base_url = reverse('api-v1:features:feature-segment-list') - url = f"{base_url}?environment={self.environment_1.id}&feature={self.feature.id}" - segment_2 = Segment.objects.create(project=self.project, name='Segment 2') - segment_3 = Segment.objects.create(project=self.project, name='Segment 3') + base_url = reverse("api-v1:features:feature-segment-list") + url = ( + f"{base_url}?environment={self.environment_1.id}&feature={self.feature.id}" + ) + segment_2 = Segment.objects.create(project=self.project, name="Segment 2") + segment_3 = Segment.objects.create(project=self.project, name="Segment 3") FeatureSegment.objects.create( - feature=self.feature, segment=self.segment, environment=self.environment_1, value="123", value_type=INTEGER + feature=self.feature, + segment=self.segment, + environment=self.environment_1, + value="123", + value_type=INTEGER, ) FeatureSegment.objects.create( - feature=self.feature, segment=segment_2, environment=self.environment_1, value="True", value_type=BOOLEAN + feature=self.feature, + segment=segment_2, + environment=self.environment_1, + value="True", + value_type=BOOLEAN, ) FeatureSegment.objects.create( - feature=self.feature, segment=segment_3, environment=self.environment_1, value="str", value_type=STRING + feature=self.feature, + segment=segment_3, + environment=self.environment_1, + value="str", + value_type=STRING, + ) + FeatureSegment.objects.create( + feature=self.feature, segment=self.segment, environment=self.environment_2 ) - FeatureSegment.objects.create(feature=self.feature, segment=self.segment, environment=self.environment_2) # When response = self.client.get(url) @@ -414,12 +568,14 @@ def test_create_feature_segment_with_integer_value(self): "feature": self.feature.id, "segment": self.segment.id, "environment": self.environment_1.id, - "value": 123 + "value": 123, } url = reverse("api-v1:features:feature-segment-list") # When - response = self.client.post(url, data=json.dumps(data), content_type='application/json') + response = self.client.post( + url, data=json.dumps(data), content_type="application/json" + ) # Then assert response.status_code == status.HTTP_201_CREATED @@ -433,12 +589,14 @@ def test_create_feature_segment_with_boolean_value(self): "feature": self.feature.id, "segment": self.segment.id, "environment": self.environment_1.id, - "value": True + "value": True, } url = reverse("api-v1:features:feature-segment-list") # When - response = self.client.post(url, data=json.dumps(data), content_type='application/json') + response = self.client.post( + url, data=json.dumps(data), content_type="application/json" + ) # Then assert response.status_code == status.HTTP_201_CREATED @@ -452,12 +610,14 @@ def test_create_feature_segment_with_string_value(self): "feature": self.feature.id, "segment": self.segment.id, "environment": self.environment_1.id, - "value": "string" + "value": "string", } url = reverse("api-v1:features:feature-segment-list") # When - response = self.client.post(url, data=json.dumps(data), content_type='application/json') + response = self.client.post( + url, data=json.dumps(data), content_type="application/json" + ) # Then assert response.status_code == status.HTTP_201_CREATED @@ -471,12 +631,14 @@ def test_create_feature_segment_without_value(self): "feature": self.feature.id, "segment": self.segment.id, "environment": self.environment_1.id, - "enabled": True + "enabled": True, } url = reverse("api-v1:features:feature-segment-list") # When - response = self.client.post(url, data=json.dumps(data), content_type='application/json') + response = self.client.post( + url, data=json.dumps(data), content_type="application/json" + ) # Then assert response.status_code == status.HTTP_201_CREATED @@ -491,15 +653,17 @@ def test_update_feature_segment(self): environment=self.environment_1, segment=self.segment, value="123", - value_type=INTEGER + value_type=INTEGER, ) - url = reverse("api-v1:features:feature-segment-detail", args=[feature_segment.id]) - data = { - "value": 456 - } + url = reverse( + "api-v1:features:feature-segment-detail", args=[feature_segment.id] + ) + data = {"value": 456} # When - response = self.client.patch(url, data=json.dumps(data), content_type='application/json') + response = self.client.patch( + url, data=json.dumps(data), content_type="application/json" + ) # Then assert response.status_code == status.HTTP_200_OK @@ -511,7 +675,9 @@ def test_delete_feature_segment(self): feature_segment = FeatureSegment.objects.create( feature=self.feature, environment=self.environment_1, segment=self.segment ) - url = reverse("api-v1:features:feature-segment-detail", args=[feature_segment.id]) + url = reverse( + "api-v1:features:feature-segment-detail", args=[feature_segment.id] + ) # When response = self.client.delete(url) @@ -522,12 +688,12 @@ def test_delete_feature_segment(self): def test_audit_log_created_when_feature_segment_created(self): # Given - url = reverse('api-v1:features:feature-segment-list') + url = reverse("api-v1:features:feature-segment-list") data = { - 'segment': self.segment.id, - 'feature': self.feature.id, - 'environment': self.environment_1.id, - 'enabled': True + "segment": self.segment.id, + "feature": self.feature.id, + "environment": self.environment_1.id, + "enabled": True, } # When @@ -535,73 +701,98 @@ def test_audit_log_created_when_feature_segment_created(self): # Then assert response.status_code == status.HTTP_201_CREATED - assert AuditLog.objects.filter(related_object_type=RelatedObjectType.FEATURE.name).count() == 1 + assert ( + AuditLog.objects.filter( + related_object_type=RelatedObjectType.FEATURE.name + ).count() + == 1 + ) def test_priority_of_multiple_feature_segments(self): # Given - url = reverse('api-v1:features:feature-segment-update-priorities') + url = reverse("api-v1:features:feature-segment-update-priorities") # another segment and 2 feature segments for the same feature / the 2 segments - another_segment = Segment.objects.create(name='Another segment', project=self.project) - feature_segment_default_data = {"environment": self.environment_1, "feature": self.feature} - feature_segment_1 = FeatureSegment.objects.create(segment=self.segment, **feature_segment_default_data) - feature_segment_2 = FeatureSegment.objects.create(segment=another_segment, **feature_segment_default_data) + another_segment = Segment.objects.create( + name="Another segment", project=self.project + ) + feature_segment_default_data = { + "environment": self.environment_1, + "feature": self.feature, + } + feature_segment_1 = FeatureSegment.objects.create( + segment=self.segment, **feature_segment_default_data + ) + feature_segment_2 = FeatureSegment.objects.create( + segment=another_segment, **feature_segment_default_data + ) # reorder the feature segments assert feature_segment_1.priority == 0 assert feature_segment_2.priority == 1 data = [ { - 'id': feature_segment_1.id, - 'priority': 1, + "id": feature_segment_1.id, + "priority": 1, }, { - 'id': feature_segment_2.id, - 'priority': 0, + "id": feature_segment_2.id, + "priority": 0, }, ] # When - response = self.client.post(url, data=json.dumps(data), content_type='application/json') + response = self.client.post( + url, data=json.dumps(data), content_type="application/json" + ) # Then the segments are reordered assert response.status_code == status.HTTP_200_OK json_response = response.json() - assert json_response[0]['id'] == feature_segment_1.id - assert json_response[1]['id'] == feature_segment_2.id + assert json_response[0]["id"] == feature_segment_1.id + assert json_response[1]["id"] == feature_segment_2.id @pytest.mark.django_db() class FeatureStateViewSetTestCase(TestCase): def setUp(self) -> None: - self.organisation = Organisation.objects.create(name='Test org') - self.project = Project.objects.create(name='Test project', organisation=self.organisation) - self.environment = Environment.objects.create(project=self.project, name='Test environment') - self.feature = Feature.objects.create(name='test-feature', project=self.project, type='CONFIG', - initial_value=12) - self.user = FFAdminUser.objects.create(email='test@example.com') + self.organisation = Organisation.objects.create(name="Test org") + self.project = Project.objects.create( + name="Test project", organisation=self.organisation + ) + self.environment = Environment.objects.create( + project=self.project, name="Test environment" + ) + self.feature = Feature.objects.create( + name="test-feature", project=self.project, type="CONFIG", initial_value=12 + ) + self.user = FFAdminUser.objects.create(email="test@example.com") self.user.add_organisation(self.organisation, OrganisationRole.ADMIN) self.client = APIClient() self.client.force_authenticate(self.user) def test_update_feature_state_value_updates_feature_state_value(self): # Given - feature_state = FeatureState.objects.get(environment=self.environment, feature=self.feature) - url = reverse('api-v1:environments:environment-featurestates-detail', - args=[self.environment.api_key, feature_state.id]) - new_value = 'new-value' + feature_state = FeatureState.objects.get( + environment=self.environment, feature=self.feature + ) + url = reverse( + "api-v1:environments:environment-featurestates-detail", + args=[self.environment.api_key, feature_state.id], + ) + new_value = "new-value" data = { - 'id': feature_state.id, - 'feature_state_value': new_value, - 'enabled': False, - 'feature': self.feature.id, - 'environment': self.environment.id, - 'identity': None, - 'feature_segment': None + "id": feature_state.id, + "feature_state_value": new_value, + "enabled": False, + "feature": self.feature.id, + "environment": self.environment.id, + "identity": None, + "feature_segment": None, } # When - self.client.put(url, data=json.dumps(data), content_type='application/json') + self.client.put(url, data=json.dumps(data), content_type="application/json") # Then feature_state.refresh_from_db() @@ -609,15 +800,23 @@ def test_update_feature_state_value_updates_feature_state_value(self): def test_can_filter_feature_states_to_show_identity_overrides_only(self): # Given - feature_state = FeatureState.objects.get(environment=self.environment, feature=self.feature) + feature_state = FeatureState.objects.get( + environment=self.environment, feature=self.feature + ) - identifier = 'test-identity' - identity = Identity.objects.create(identifier=identifier, environment=self.environment) - identity_feature_state = FeatureState.objects.create(environment=self.environment, feature=self.feature, - identity=identity) + identifier = "test-identity" + identity = Identity.objects.create( + identifier=identifier, environment=self.environment + ) + identity_feature_state = FeatureState.objects.create( + environment=self.environment, feature=self.feature, identity=identity + ) - base_url = reverse('api-v1:environments:environment-featurestates-list', args=[self.environment.api_key]) - url = base_url + '?anyIdentity&feature=' + str(self.feature.id) + base_url = reverse( + "api-v1:environments:environment-featurestates-list", + args=[self.environment.api_key], + ) + url = base_url + "?anyIdentity&feature=" + str(self.feature.id) # When res = self.client.get(url) @@ -626,30 +825,50 @@ def test_can_filter_feature_states_to_show_identity_overrides_only(self): assert res.status_code == status.HTTP_200_OK # and - assert len(res.json().get('results')) == 1 + assert len(res.json().get("results")) == 1 # and - assert res.json()['results'][0]['identity']['identifier'] == identifier + assert res.json()["results"][0]["identity"]["identifier"] == identifier @pytest.mark.django_db class SDKFeatureStatesTestCase(APITestCase): def setUp(self) -> None: - self.environment_fs_value = 'environment' - self.identity_fs_value = 'identity' - self.segment_fs_value = 'segment' - - self.organisation = Organisation.objects.create(name='Test organisation') - self.project = Project.objects.create(name='Test project', organisation=self.organisation) - self.environment = Environment.objects.create(name='Test environment', project=self.project) - self.feature = Feature.objects.create(name='Test feature', project=self.project, type=CONFIG, initial_value=self.environment_fs_value) - segment = Segment.objects.create(name='Test segment', project=self.project) - FeatureSegment.objects.create(segment=segment, feature=self.feature, value=self.segment_fs_value, environment=self.environment) - identity = Identity.objects.create(identifier='test', environment=self.environment) - identity_feature_state = FeatureState.objects.create(identity=identity, environment=self.environment, feature=self.feature) - FeatureStateValue.objects.filter(feature_state=identity_feature_state).update(string_value=self.identity_fs_value) - - self.url = reverse('api-v1:flags') + self.environment_fs_value = "environment" + self.identity_fs_value = "identity" + self.segment_fs_value = "segment" + + self.organisation = Organisation.objects.create(name="Test organisation") + self.project = Project.objects.create( + name="Test project", organisation=self.organisation + ) + self.environment = Environment.objects.create( + name="Test environment", project=self.project + ) + self.feature = Feature.objects.create( + name="Test feature", + project=self.project, + type=CONFIG, + initial_value=self.environment_fs_value, + ) + segment = Segment.objects.create(name="Test segment", project=self.project) + FeatureSegment.objects.create( + segment=segment, + feature=self.feature, + value=self.segment_fs_value, + environment=self.environment, + ) + identity = Identity.objects.create( + identifier="test", environment=self.environment + ) + identity_feature_state = FeatureState.objects.create( + identity=identity, environment=self.environment, feature=self.feature + ) + FeatureStateValue.objects.filter(feature_state=identity_feature_state).update( + string_value=self.identity_fs_value + ) + + self.url = reverse("api-v1:flags") self.client.credentials(HTTP_X_ENVIRONMENT_KEY=self.environment.api_key) @@ -670,15 +889,25 @@ def test_get_flags_exclude_disabled(self): # Given # a project with hide_disabled_flags enabled - project_flag_disabled = Project.objects.create(name="Project Flag Disabled", - organisation=self.organisation, - hide_disabled_flags=True) + project_flag_disabled = Project.objects.create( + name="Project Flag Disabled", + organisation=self.organisation, + hide_disabled_flags=True, + ) # and a set of features and environments for that project - other_environment = Environment.objects.create(name="Test Environment 2", project=project_flag_disabled) - disabled_flag = Feature.objects.create(name="Flag 1", project=project_flag_disabled) - config_flag = Feature.objects.create(name="Config", project=project_flag_disabled, type=CONFIG) - enabled_flag = Feature.objects.create(name="Flag 2", project=project_flag_disabled, default_enabled=True) + other_environment = Environment.objects.create( + name="Test Environment 2", project=project_flag_disabled + ) + disabled_flag = Feature.objects.create( + name="Flag 1", project=project_flag_disabled + ) + config_flag = Feature.objects.create( + name="Config", project=project_flag_disabled, type=CONFIG + ) + enabled_flag = Feature.objects.create( + name="Flag 2", project=project_flag_disabled, default_enabled=True + ) # When # we get all flags for an environment @@ -698,5 +927,3 @@ def test_get_flags_exclude_disabled(self): # but enabled ones and remote configs are assert response_json[0]["feature"]["id"] == config_flag.id assert response_json[1]["feature"]["id"] == enabled_flag.id - - diff --git a/src/features/urls.py b/src/features/urls.py index 9ad5b0821bba..567fe23fa594 100644 --- a/src/features/urls.py +++ b/src/features/urls.py @@ -1,16 +1,14 @@ -from django.conf.urls import url, include +from django.conf.urls import include, url from django.urls import path from rest_framework_nested import routers -from features.views import FeatureStateCreateViewSet, FeatureSegmentViewSet +from features.views import FeatureSegmentViewSet, FeatureStateCreateViewSet router = routers.DefaultRouter() -router.register(r'featurestates', FeatureStateCreateViewSet, basename='featurestates') -router.register(r'feature-segments', FeatureSegmentViewSet, basename='feature-segment') +router.register(r"featurestates", FeatureStateCreateViewSet, basename="featurestates") +router.register(r"feature-segments", FeatureSegmentViewSet, basename="feature-segment") app_name = "features" -urlpatterns = [ - path('', include(router.urls)) -] +urlpatterns = [path("", include(router.urls))] diff --git a/src/features/utils.py b/src/features/utils.py index 811161d3bb2b..4dd1d98f5911 100644 --- a/src/features/utils.py +++ b/src/features/utils.py @@ -28,7 +28,7 @@ def is_integer(value): def is_boolean(value): - return value in ('true', 'True', 'false', 'False') + return value in ("true", "True", "false", "False") def get_integer_from_string(value): @@ -39,7 +39,7 @@ def get_integer_from_string(value): def get_boolean_from_string(value): - if value in ('false', 'False'): + if value in ("false", "False"): return False else: return True diff --git a/src/features/views.py b/src/features/views.py index a370d72e3382..1ef93653f810 100644 --- a/src/features/views.py +++ b/src/features/views.py @@ -6,27 +6,43 @@ from django.utils.decorators import method_decorator from drf_yasg2 import openapi from drf_yasg2.utils import swagger_auto_schema -from rest_framework import status, viewsets, mixins +from rest_framework import mixins, status, viewsets from rest_framework.decorators import action from rest_framework.generics import GenericAPIView, get_object_or_404 from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.schemas import AutoSchema -from audit.models import AuditLog, RelatedObjectType, IDENTITY_FEATURE_STATE_DELETED_MESSAGE +from audit.models import ( + IDENTITY_FEATURE_STATE_DELETED_MESSAGE, + AuditLog, + RelatedObjectType, +) from environments.authentication import EnvironmentKeyAuthentication -from environments.models import Environment from environments.identities.models import Identity -from environments.permissions.permissions import EnvironmentKeyPermissions, NestedEnvironmentPermissions +from environments.models import Environment +from environments.permissions.permissions import ( + EnvironmentKeyPermissions, + NestedEnvironmentPermissions, +) from projects.models import Project -from .models import FeatureState, FeatureSegment, FLAG + +from .models import FLAG, FeatureSegment, FeatureState from .permissions import FeaturePermissions, FeatureStatePermissions -from .serializers import FeatureStateSerializerBasic, FeatureStateSerializerFull, \ - FeatureStateSerializerCreate, CreateFeatureSerializer, \ - FeatureStateValueSerializer, FeatureSegmentCreateSerializer, \ - FeatureStateSerializerWithIdentity, \ - FeatureSegmentListSerializer, FeatureSegmentQuerySerializer, \ - FeatureSegmentChangePrioritiesSerializer, FeatureSerializer, FeatureWithTagsSerializer +from .serializers import ( + CreateFeatureSerializer, + FeatureSegmentChangePrioritiesSerializer, + FeatureSegmentCreateSerializer, + FeatureSegmentListSerializer, + FeatureSegmentQuerySerializer, + FeatureSerializer, + FeatureStateSerializerBasic, + FeatureStateSerializerCreate, + FeatureStateSerializerFull, + FeatureStateSerializerWithIdentity, + FeatureStateValueSerializer, + FeatureWithTagsSerializer, +) logger = logging.getLogger() logger.setLevel(logging.INFO) @@ -38,21 +54,21 @@ class FeatureViewSet(viewsets.ModelViewSet): permission_classes = [IsAuthenticated, FeaturePermissions] def get_serializer_class(self): - if self.action == 'list': + if self.action == "list": return FeatureWithTagsSerializer - elif self.action in ['create', 'update']: + elif self.action in ["create", "update"]: return CreateFeatureSerializer else: return FeatureSerializer def get_queryset(self): user_projects = self.request.user.get_permitted_projects(["VIEW_PROJECT"]) - project = get_object_or_404(user_projects, pk=self.kwargs['project_pk']) + 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_id = request.data.get("project") project = Project.objects.get(pk=project_id) if project.organisation not in request.user.organisations.all(): @@ -61,29 +77,41 @@ def create(self, request, *args, **kwargs): return super().create(request, *args, **kwargs) -@method_decorator(name='list', decorator=swagger_auto_schema( - manual_parameters=[ - openapi.Parameter( - 'feature', openapi.IN_QUERY, 'ID of the feature to filter by.', required=False, type=openapi.TYPE_INTEGER), - openapi.Parameter( - 'anyIdentity', openapi.IN_QUERY, 'Pass any value to get results that have an identity override. ' - 'Do not pass for default behaviour.', - required=False, type=openapi.TYPE_STRING - ) - ] -)) +@method_decorator( + name="list", + decorator=swagger_auto_schema( + manual_parameters=[ + openapi.Parameter( + "feature", + openapi.IN_QUERY, + "ID of the feature to filter by.", + required=False, + type=openapi.TYPE_INTEGER, + ), + openapi.Parameter( + "anyIdentity", + openapi.IN_QUERY, + "Pass any value to get results that have an identity override. " + "Do not pass for default behaviour.", + required=False, + type=openapi.TYPE_STRING, + ), + ] + ), +) class FeatureStateViewSet(viewsets.ModelViewSet): """ View set to manage feature states. Nested beneath environments and environments + identities to allow for filtering on both. """ + permission_classes = [IsAuthenticated, NestedEnvironmentPermissions] # Override serializer class to show correct information in docs def get_serializer_class(self): - if self.action == 'list': + if self.action == "list": return FeatureStateSerializerWithIdentity - elif self.action in ['retrieve', 'update']: + elif self.action in ["retrieve", "update"]: return FeatureStateSerializerBasic else: return FeatureStateSerializerCreate @@ -92,22 +120,28 @@ def get_queryset(self): """ Override queryset to filter based on provided URL parameters. """ - environment_api_key = self.kwargs['environment_api_key'] - identity_pk = self.kwargs.get('identity_pk') - environment = get_object_or_404(self.request.user.get_permitted_environments(['VIEW_ENVIRONMENT']), - api_key=environment_api_key) + environment_api_key = self.kwargs["environment_api_key"] + identity_pk = self.kwargs.get("identity_pk") + environment = get_object_or_404( + self.request.user.get_permitted_environments(["VIEW_ENVIRONMENT"]), + api_key=environment_api_key, + ) - queryset = FeatureState.objects.filter(environment=environment, feature_segment=None) + queryset = FeatureState.objects.filter( + environment=environment, feature_segment=None + ) if identity_pk: queryset = queryset.filter(identity__pk=identity_pk) - elif 'anyIdentity' in self.request.query_params: + elif "anyIdentity" in self.request.query_params: queryset = queryset.exclude(identity=None) else: queryset = queryset.filter(identity=None, feature_segment=None) - if self.request.query_params.get('feature'): - queryset = queryset.filter(feature__id=int(self.request.query_params.get('feature'))) + if self.request.query_params.get("feature"): + queryset = queryset.filter( + feature__id=int(self.request.query_params.get("feature")) + ) return queryset @@ -116,14 +150,15 @@ def get_environment_from_request(self): Get environment object from URL parameters in request. """ environment = Environment.objects.get( - api_key=self.kwargs['environment_api_key']) + api_key=self.kwargs["environment_api_key"] + ) return environment def get_identity_from_request(self, environment): """ Get identity object from URL parameters in request. """ - identity = Identity.objects.get(pk=self.kwargs['identity_pk']) + identity = Identity.objects.get(pk=self.kwargs["identity_pk"]) return identity def create(self, request, *args, **kwargs): @@ -133,35 +168,45 @@ 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(): + if ( + environment.project.organisation + not in self.request.user.organisations.all() + ): return Response(status.HTTP_403_FORBIDDEN) - data['environment'] = environment.id + data["environment"] = environment.id - if 'feature' not in data: + if "feature" not in data: error = {"detail": "Feature not provided"} return Response(error, status=status.HTTP_400_BAD_REQUEST) - feature_id = int(data['feature']) + feature_id = int(data["feature"]) - if feature_id not in [feature.id for feature in environment.project.features.all()]: + if feature_id not in [ + feature.id for feature in environment.project.features.all() + ]: error = {"detail": "Feature does not exist in project"} return Response(error, status=status.HTTP_400_BAD_REQUEST) - identity_pk = self.kwargs.get('identity_pk') + identity_pk = self.kwargs.get("identity_pk") if identity_pk: - data['identity'] = identity_pk + data["identity"] = identity_pk serializer = FeatureStateSerializerBasic(data=data) if serializer.is_valid(): feature_state = serializer.save() headers = self.get_success_headers(serializer.data) - if 'feature_state_value' in data: - self.update_feature_state_value(data['feature_state_value'], feature_state) + if "feature_state_value" in data: + self.update_feature_state_value( + data["feature_state_value"], feature_state + ) - return Response(FeatureStateSerializerBasic(feature_state).data, - status=status.HTTP_201_CREATED, headers=headers) + return Response( + FeatureStateSerializerBasic(feature_state).data, + status=status.HTTP_201_CREATED, + headers=headers, + ) else: logger.error(serializer.errors) error = {"detail": "Couldn't create feature state."} @@ -177,23 +222,23 @@ def update(self, request, *args, **kwargs): # Check if feature state value was provided with request data. If so, create / update # feature state value object and associate with feature state. - if 'feature_state_value' in feature_state_data: + if "feature_state_value" in feature_state_data: feature_state_value = self.update_feature_state_value( - feature_state_data['feature_state_value'], - feature_state_to_update + feature_state_data["feature_state_value"], feature_state_to_update ) if isinstance(feature_state_value, Response): return feature_state_value - feature_state_data['feature_state_value'] = feature_state_value.id + feature_state_data["feature_state_value"] = feature_state_value.id - serializer = self.get_serializer(feature_state_to_update, data=feature_state_data, - partial=True) + serializer = self.get_serializer( + feature_state_to_update, data=feature_state_data, partial=True + ) serializer.is_valid(raise_exception=True) self.perform_update(serializer) - if getattr(feature_state_to_update, '_prefetched_objects_cache', None): + if getattr(feature_state_to_update, "_prefetched_objects_cache", None): # If 'prefetch_related' has been applied to a queryset, we need to # refresh the instance from the database. feature_state_to_update = self.get_object() @@ -202,22 +247,26 @@ def update(self, request, *args, **kwargs): return Response(serializer.data) def destroy(self, request, *args, **kwargs): - feature_state = get_object_or_404(self.get_queryset(), pk=kwargs.get('pk')) + feature_state = get_object_or_404(self.get_queryset(), pk=kwargs.get("pk")) res = super(FeatureStateViewSet, self).destroy(request, *args, **kwargs) if res.status_code == status.HTTP_204_NO_CONTENT: self._create_deleted_feature_state_audit_log(feature_state) return res def _create_deleted_feature_state_audit_log(self, feature_state): - message = IDENTITY_FEATURE_STATE_DELETED_MESSAGE % (feature_state.feature.name, - feature_state.identity.identifier) + message = IDENTITY_FEATURE_STATE_DELETED_MESSAGE % ( + feature_state.feature.name, + feature_state.identity.identifier, + ) - AuditLog.objects.create(author=self.request.user if self.request else None, - related_object_id=feature_state.id, - related_object_type=RelatedObjectType.FEATURE_STATE.name, - environment=feature_state.environment, - project=feature_state.environment.project, - log=message) + AuditLog.objects.create( + author=self.request.user if self.request else None, + related_object_id=feature_state.id, + related_object_type=RelatedObjectType.FEATURE_STATE.name, + environment=feature_state.environment, + project=feature_state.environment.project, + log=message, + ) def partial_update(self, request, *args, **kwargs): """ @@ -227,25 +276,25 @@ def partial_update(self, request, *args, **kwargs): def update_feature_state_value(self, value, feature_state): feature_state_value_dict = feature_state.generate_feature_state_value_data( - value) + value + ) if hasattr(feature_state, "feature_state_value"): feature_state_value_serializer = FeatureStateValueSerializer( instance=feature_state.feature_state_value, - data=feature_state_value_dict + data=feature_state_value_dict, ) else: - data = { - **feature_state_value_dict, - 'feature_state': feature_state.id - } + data = {**feature_state_value_dict, "feature_state": feature_state.id} feature_state_value_serializer = FeatureStateValueSerializer(data=data) if feature_state_value_serializer.is_valid(): feature_state_value = feature_state_value_serializer.save() else: - return Response(feature_state_value_serializer.errors, - status=status.HTTP_400_BAD_REQUEST) + return Response( + feature_state_value_serializer.errors, + status=status.HTTP_400_BAD_REQUEST, + ) return feature_state_value @@ -262,10 +311,16 @@ class SDKFeatureStates(GenericAPIView): schema = AutoSchema( manual_fields=[ - coreapi.Field("X-Environment-Key", location="header", - description="API Key for an Environment"), - coreapi.Field("feature", location="query", - description="Name of the feature to get the state of") + coreapi.Field( + "X-Environment-Key", + location="header", + description="API Key for an Environment", + ), + coreapi.Field( + "feature", + location="query", + description="Name of the feature to get the state of", + ), ] ) @@ -278,17 +333,20 @@ def get(self, request, identifier=None, *args, **kwargs): return self._get_flags_response_with_identifier(request, identifier) filter_args = { - 'identity': None, - 'environment': request.environment, - 'feature_segment': None + "identity": None, + "environment": request.environment, + "feature_segment": None, } - if 'feature' in request.GET: - filter_args['feature__name__iexact'] = request.GET['feature'] + if "feature" in request.GET: + filter_args["feature__name__iexact"] = request.GET["feature"] try: feature_state = FeatureState.objects.get(**filter_args) except FeatureState.DoesNotExist: - return Response({"detail": "Given feature not found"}, status=status.HTTP_404_NOT_FOUND) + return Response( + {"detail": "Given feature not found"}, + status=status.HTTP_404_NOT_FOUND, + ) return Response(self.get_serializer(feature_state).data) @@ -297,11 +355,15 @@ def get(self, request, identifier=None, *args, **kwargs): else: data = self.get_serializer( # ignore disabled Flags when project hide_disabled_flags is enabled - FeatureState.objects.filter(**filter_args).exclude( + FeatureState.objects.filter(**filter_args) + .exclude( feature__project__hide_disabled_flags=True, enabled=False, - feature__type=FLAG - ).select_related("feature", "feature_state_value"), many=True).data + feature__type=FLAG, + ) + .select_related("feature", "feature_state_value"), + many=True, + ).data return Response(data) @@ -310,11 +372,15 @@ def _get_flags_from_cache(self, filter_args, environment): if not data: data = self.get_serializer( # ignore disabled Flags when project hide_disabled_flags is enabled - FeatureState.objects.filter(**filter_args).exclude( + FeatureState.objects.filter(**filter_args) + .exclude( feature__project__hide_disabled_flags=True, enabled=False, - feature__type=FLAG - ).select_related("feature", "feature_state_value"), many=True).data + feature__type=FLAG, + ) + .select_related("feature", "feature_state_value"), + many=True, + ).data flags_cache.set(environment.api_key, data, settings.CACHE_FLAGS_SECONDS) return data @@ -326,27 +392,28 @@ def _get_flags_response_with_identifier(self, request, identifier): ) kwargs = { - 'identity': identity, - 'environment': request.environment, - 'feature_segment': None + "identity": identity, + "environment": request.environment, + "feature_segment": None, } - if 'feature' in request.GET: - kwargs['feature__name__iexact'] = request.GET['feature'] + if "feature" in request.GET: + kwargs["feature__name__iexact"] = request.GET["feature"] try: feature_state = identity.get_all_feature_states().get( - feature__name__iexact=kwargs['feature__name__iexact'], + feature__name__iexact=kwargs["feature__name__iexact"], ) except FeatureState.DoesNotExist: return Response( {"detail": "Given feature not found"}, - status=status.HTTP_404_NOT_FOUND + status=status.HTTP_404_NOT_FOUND, ) - return Response(self.get_serializer(feature_state).data, status=status.HTTP_200_OK) + return Response( + self.get_serializer(feature_state).data, status=status.HTTP_200_OK + ) - flags = self.get_serializer( - identity.get_all_feature_states(), many=True) + flags = self.get_serializer(identity.get_all_feature_states(), many=True) return Response(flags.data, status=status.HTTP_200_OK) @@ -370,46 +437,58 @@ def organisation_has_got_feature(request, organisation): return True -@method_decorator(name='list', decorator=swagger_auto_schema(query_serializer=FeatureSegmentQuerySerializer())) @method_decorator( - name='update_priorities', decorator=swagger_auto_schema(responses={200: FeatureSegmentListSerializer(many=True)}) + name="list", + decorator=swagger_auto_schema(query_serializer=FeatureSegmentQuerySerializer()), +) +@method_decorator( + name="update_priorities", + decorator=swagger_auto_schema( + responses={200: FeatureSegmentListSerializer(many=True)} + ), ) class FeatureSegmentViewSet( mixins.ListModelMixin, mixins.CreateModelMixin, mixins.UpdateModelMixin, mixins.DestroyModelMixin, - viewsets.GenericViewSet + viewsets.GenericViewSet, ): def get_queryset(self): - permitted_projects = self.request.user.get_permitted_projects(['VIEW_PROJECT']) - queryset = FeatureSegment.objects.filter(feature__project__in=permitted_projects) + permitted_projects = self.request.user.get_permitted_projects(["VIEW_PROJECT"]) + queryset = FeatureSegment.objects.filter( + feature__project__in=permitted_projects + ) - if self.action == 'list': - filter_serializer = FeatureSegmentQuerySerializer(data=self.request.query_params) + if self.action == "list": + filter_serializer = FeatureSegmentQuerySerializer( + data=self.request.query_params + ) filter_serializer.is_valid(raise_exception=True) return queryset.filter(**filter_serializer.data) return queryset def get_serializer_class(self): - if self.action in ['create', 'update', 'partial_update']: + if self.action in ["create", "update", "partial_update"]: return FeatureSegmentCreateSerializer - if self.action == 'update_priorities': + if self.action == "update_priorities": return FeatureSegmentChangePrioritiesSerializer return FeatureSegmentListSerializer def get_serializer(self, *args, **kwargs): - if self.action == 'update_priorities': + if self.action == "update_priorities": # update the serializer kwargs to ensure docs here are correct - kwargs = {**kwargs, 'many': True, 'partial': True} + kwargs = {**kwargs, "many": True, "partial": True} return super(FeatureSegmentViewSet, self).get_serializer(*args, **kwargs) - @action(detail=False, methods=['POST'], url_path='update-priorities') + @action(detail=False, methods=["POST"], url_path="update-priorities") def update_priorities(self, request, *args, **kwargs): serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) updated_instances = serializer.save() - return Response(FeatureSegmentListSerializer(instance=updated_instances, many=True).data) + return Response( + FeatureSegmentListSerializer(instance=updated_instances, many=True).data + ) diff --git a/src/integrations/amplitude/__init__.py b/src/integrations/amplitude/__init__.py index 8fdceb7ce638..4b388ef7e73a 100644 --- a/src/integrations/amplitude/__init__.py +++ b/src/integrations/amplitude/__init__.py @@ -1 +1 @@ -default_app_config = 'integrations.amplitude.apps.AmplitudeIntegrationConfig' +default_app_config = "integrations.amplitude.apps.AmplitudeIntegrationConfig" diff --git a/src/integrations/amplitude/amplitude.py b/src/integrations/amplitude/amplitude.py index 60754150847c..a08729b07a54 100644 --- a/src/integrations/amplitude/amplitude.py +++ b/src/integrations/amplitude/amplitude.py @@ -19,7 +19,9 @@ def _identify_user(self, user_data: dict) -> None: self.url = self.url + "&" + urllib.parse.urlencode(user_data) response = requests.post(self.url) - logger.debug("Sent event to Amplitude. Response code was: %s" % response.status_code) + logger.debug( + "Sent event to Amplitude. Response code was: %s" % response.status_code + ) @postpone def identify_user_async(self, user_data: dict) -> None: @@ -31,9 +33,10 @@ def generate_user_data(self, user_id, feature_states): "user_id": user_id, "user_properties": { feature_state.feature.name: feature_state.get_feature_state_value() - if feature_state.get_feature_state_value() is not None else "None" + if feature_state.get_feature_state_value() is not None + else "None" for feature_state in feature_states - } + }, } } diff --git a/src/integrations/amplitude/apps.py b/src/integrations/amplitude/apps.py index 620485e31fc2..b9dbe7ad7aad 100644 --- a/src/integrations/amplitude/apps.py +++ b/src/integrations/amplitude/apps.py @@ -3,4 +3,4 @@ class AmplitudeIntegrationConfig(AppConfig): - name = 'integrations.amplitude' + name = "integrations.amplitude" diff --git a/src/integrations/amplitude/models.py b/src/integrations/amplitude/models.py index 3e0c0b9964c7..9783d25dcaf9 100644 --- a/src/integrations/amplitude/models.py +++ b/src/integrations/amplitude/models.py @@ -5,5 +5,7 @@ class AmplitudeConfiguration(models.Model): api_key = models.CharField(max_length=100, blank=False, null=False) - environment = models.OneToOneField(Environment, related_name='amplitude_config', on_delete=models.CASCADE) - created_date = models.DateTimeField('DateCreated', auto_now_add=True) + environment = models.OneToOneField( + Environment, related_name="amplitude_config", on_delete=models.CASCADE + ) + created_date = models.DateTimeField("DateCreated", auto_now_add=True) diff --git a/src/integrations/amplitude/serializers.py b/src/integrations/amplitude/serializers.py index de24ad1b6bed..37832311794c 100644 --- a/src/integrations/amplitude/serializers.py +++ b/src/integrations/amplitude/serializers.py @@ -6,4 +6,4 @@ class AmplitudeConfigurationSerializer(serializers.ModelSerializer): class Meta: model = AmplitudeConfiguration - fields = ('id', 'api_key') + fields = ("id", "api_key") diff --git a/src/integrations/amplitude/tests/test_amplitude.py b/src/integrations/amplitude/tests/test_amplitude.py index 460819ea4703..3884bb3aaa3c 100644 --- a/src/integrations/amplitude/tests/test_amplitude.py +++ b/src/integrations/amplitude/tests/test_amplitude.py @@ -2,14 +2,17 @@ from environments.models import Environment from features.models import Feature, FeatureState -from integrations.amplitude.amplitude import AmplitudeWrapper, AMPLITUDE_API_URL +from integrations.amplitude.amplitude import ( + AMPLITUDE_API_URL, + AmplitudeWrapper, +) from organisations.models import Organisation from projects.models import Project def test_amplitude_initialized_correctly(): # Given - api_key = '123key' + api_key = "123key" # When initialized amplitude_wrapper = AmplitudeWrapper(api_key=api_key) @@ -22,19 +25,22 @@ def test_amplitude_initialized_correctly(): @pytest.mark.django_db def test_amplitude_when_generate_user_data_with_correct_values_then_success(): # Given - api_key = '123key' - user_id = 'user123' + api_key = "123key" + user_id = "user123" amplitude_wrapper = AmplitudeWrapper(api_key=api_key) organisation = Organisation.objects.create(name="Test Org") project = Project.objects.create(name="Test Project", organisation=organisation) - environment_one = Environment.objects.create(name="Test Environment 1", project=project) + environment_one = Environment.objects.create( + name="Test Environment 1", project=project + ) feature = Feature.objects.create(name="Test Feature", project=project) feature_states = FeatureState.objects.filter(feature=feature) # When - user_data = amplitude_wrapper.generate_user_data(user_id=user_id, - feature_states=feature_states) + user_data = amplitude_wrapper.generate_user_data( + user_id=user_id, feature_states=feature_states + ) # Then expected_user_data = { @@ -42,9 +48,10 @@ def test_amplitude_when_generate_user_data_with_correct_values_then_success(): "user_id": user_id, "user_properties": { feature_state.feature.name: feature_state.get_feature_state_value() - if feature_state.get_feature_state_value() is not None else "None" + if feature_state.get_feature_state_value() is not None + else "None" for feature_state in feature_states - } + }, } } assert expected_user_data == user_data diff --git a/src/integrations/amplitude/tests/test_views.py b/src/integrations/amplitude/tests/test_views.py index ea51e94e4395..1b0524386192 100644 --- a/src/integrations/amplitude/tests/test_views.py +++ b/src/integrations/amplitude/tests/test_views.py @@ -15,7 +15,6 @@ @pytest.mark.django_db class AmplitudeConfigurationTestCase(TestCase): - def setUp(self): self.client = APIClient() user = Helper.create_ffadminuser() @@ -32,13 +31,14 @@ def setUp(self): self.environment = Environment.objects.create( name="Test Environment", project=self.project ) - self.list_url = reverse('api-v1:environments:integrations-amplitude-list', args=[self.environment.api_key]) + self.list_url = reverse( + "api-v1:environments:integrations-amplitude-list", + args=[self.environment.api_key], + ) def test_should_create_amplitude_config_when_post(self): # Given - data = { - 'api_key': 'abc-123' - } + data = {"api_key": "abc-123"} # When response = self.client.post( @@ -49,16 +49,19 @@ def test_should_create_amplitude_config_when_post(self): # Then assert response.status_code == status.HTTP_201_CREATED - assert AmplitudeConfiguration.objects.filter(environment=self.environment).count() == 1 + assert ( + AmplitudeConfiguration.objects.filter(environment=self.environment).count() + == 1 + ) def test_should_return_BadRequest_when_duplicate_amplitude_config_is_posted(self): # Given - config = AmplitudeConfiguration.objects.create(api_key="api_123", environment=self.environment) + config = AmplitudeConfiguration.objects.create( + api_key="api_123", environment=self.environment + ) # When - data = { - 'api_key': config.api_key - } + data = {"api_key": config.api_key} response = self.client.post( self.list_url, data=json.dumps(data), @@ -67,20 +70,25 @@ def test_should_return_BadRequest_when_duplicate_amplitude_config_is_posted(self # Then assert response.status_code == status.HTTP_400_BAD_REQUEST - assert AmplitudeConfiguration.objects.filter(environment=self.environment).count() == 1 + assert ( + AmplitudeConfiguration.objects.filter(environment=self.environment).count() + == 1 + ) def test_should_update_configuration_when_put(self): # Given - config = AmplitudeConfiguration.objects.create(api_key="api_123", environment=self.environment) + config = AmplitudeConfiguration.objects.create( + api_key="api_123", environment=self.environment + ) api_key_updated = "new api" - data = { - 'api_key': api_key_updated - } + data = {"api_key": api_key_updated} # When - url = reverse('api-v1:environments:integrations-amplitude-detail', - args=[self.environment.api_key, config.id]) + url = reverse( + "api-v1:environments:integrations-amplitude-detail", + args=[self.environment.api_key, config.id], + ) response = self.client.put( url, data=json.dumps(data), @@ -103,14 +111,20 @@ def test_should_return_amplitude_config_list_when_requested(self): def test_should_remove_configuration_when_delete(self): # Given - config = AmplitudeConfiguration.objects.create(api_key="api_123", environment=self.environment) + config = AmplitudeConfiguration.objects.create( + api_key="api_123", environment=self.environment + ) # When - url = reverse('api-v1:environments:integrations-amplitude-detail', - args=[self.environment.api_key, config.id]) + url = reverse( + "api-v1:environments:integrations-amplitude-detail", + args=[self.environment.api_key, config.id], + ) res = self.client.delete(url) # Then assert res.status_code == status.HTTP_204_NO_CONTENT # and - assert not AmplitudeConfiguration.objects.filter(environment=self.environment).exists() + assert not AmplitudeConfiguration.objects.filter( + environment=self.environment + ).exists() diff --git a/src/integrations/amplitude/views.py b/src/integrations/amplitude/views.py index ecb981ebdcfc..5f19482bd31a 100644 --- a/src/integrations/amplitude/views.py +++ b/src/integrations/amplitude/views.py @@ -4,6 +4,7 @@ from environments.models import Environment from integrations.amplitude.serializers import AmplitudeConfigurationSerializer + from .models import AmplitudeConfiguration @@ -12,9 +13,11 @@ class AmplitudeConfigurationViewSet(viewsets.ModelViewSet): pagination_class = None # set here to ensure documentation is correct def get_queryset(self): - environment_api_key = self.kwargs['environment_api_key'] - environment = get_object_or_404(self.request.user.get_permitted_environments(['VIEW_ENVIRONMENT']), - api_key=environment_api_key) + environment_api_key = self.kwargs["environment_api_key"] + environment = get_object_or_404( + self.request.user.get_permitted_environments(["VIEW_ENVIRONMENT"]), + api_key=environment_api_key, + ) return AmplitudeConfiguration.objects.filter(environment=environment) @@ -22,7 +25,9 @@ def perform_create(self, serializer): environment = self.get_environment_from_request() if AmplitudeConfiguration.objects.filter(environment=environment).exists(): - raise ValidationError("AmplitudeConfiguration for environment already exist.") + raise ValidationError( + "AmplitudeConfiguration for environment already exist." + ) serializer.save(environment=environment) diff --git a/src/integrations/datadog/__init__.py b/src/integrations/datadog/__init__.py index bcad91f25612..e07a4e1adf9f 100644 --- a/src/integrations/datadog/__init__.py +++ b/src/integrations/datadog/__init__.py @@ -1 +1 @@ -default_app_config = 'integrations.datadog.apps.DataDogConfigurationConfig' +default_app_config = "integrations.datadog.apps.DataDogConfigurationConfig" diff --git a/src/integrations/datadog/apps.py b/src/integrations/datadog/apps.py index 2d4aabaacab7..209e25e6a80c 100644 --- a/src/integrations/datadog/apps.py +++ b/src/integrations/datadog/apps.py @@ -3,4 +3,4 @@ class DataDogConfigurationConfig(AppConfig): - name = 'integrations.datadog' + name = "integrations.datadog" diff --git a/src/integrations/datadog/datadog.py b/src/integrations/datadog/datadog.py index 2a5aa5f21869..e7fb7718b5b2 100644 --- a/src/integrations/datadog/datadog.py +++ b/src/integrations/datadog/datadog.py @@ -1,6 +1,7 @@ import json import requests + from util.logging import get_logger from util.util import postpone @@ -17,7 +18,9 @@ def __init__(self, base_url: str, api_key: str): def _track_event(self, event: dict) -> None: response = requests.post(self.url, data=json.dumps(event)) - logger.debug("Sent event to DataDog. Response code was %s" % response.status_code) + logger.debug( + "Sent event to DataDog. Response code was %s" % response.status_code + ) @postpone def track_event_async(self, event: dict) -> None: @@ -28,7 +31,7 @@ def generate_event_data(log: str, email: str, environment_name: str): event_data = { "text": f"{log} by user {email}", "title": "Bullet Train Feature Flag Event", - "tags": [f"env:{environment_name}"] + "tags": [f"env:{environment_name}"], } return event_data diff --git a/src/integrations/datadog/models.py b/src/integrations/datadog/models.py index 7fa02e917986..f86055cb45c0 100644 --- a/src/integrations/datadog/models.py +++ b/src/integrations/datadog/models.py @@ -7,6 +7,8 @@ class DataDogConfiguration(models.Model): - project = models.OneToOneField(Project, on_delete=models.CASCADE, related_name="data_dog_config") + project = models.OneToOneField( + Project, on_delete=models.CASCADE, related_name="data_dog_config" + ) base_url = models.URLField(blank=False, null=False) api_key = models.CharField(max_length=100, blank=False, null=False) diff --git a/src/integrations/datadog/serializers.py b/src/integrations/datadog/serializers.py index 6d09e88277b8..8bca9389b83e 100644 --- a/src/integrations/datadog/serializers.py +++ b/src/integrations/datadog/serializers.py @@ -6,4 +6,4 @@ class DataDogConfigurationSerializer(serializers.ModelSerializer): class Meta: model = DataDogConfiguration - fields = ('id', 'base_url', 'api_key') + fields = ("id", "base_url", "api_key") diff --git a/src/integrations/datadog/tests/test_datadog.py b/src/integrations/datadog/tests/test_datadog.py index bbda95c077f1..92981fdf2278 100644 --- a/src/integrations/datadog/tests/test_datadog.py +++ b/src/integrations/datadog/tests/test_datadog.py @@ -1,9 +1,9 @@ -from integrations.datadog.datadog import DataDogWrapper, EVENTS_API_URI +from integrations.datadog.datadog import EVENTS_API_URI, DataDogWrapper def test_datadog_initialized_correctly(): # Given - api_key = '123key' + api_key = "123key" base_url = "http://test.com" # When initialized @@ -19,19 +19,19 @@ def test_datatog_when_generate_event_data_with_correct_values_then_success(): log = "some log data" email = "tes@email.com" env = "test" - data_dog = DataDogWrapper(base_url="http://test.com", api_key='123key') + data_dog = DataDogWrapper(base_url="http://test.com", api_key="123key") # When - event_data = data_dog.generate_event_data(log=log, - email=email, - environment_name=env) + event_data = data_dog.generate_event_data( + log=log, email=email, environment_name=env + ) # Then expected_event_text = f"{log} by user {email}" - assert event_data['text'] == expected_event_text - assert len(event_data['tags']) == 1 - assert event_data['tags'][0] == "env:" + env + assert event_data["text"] == expected_event_text + assert len(event_data["tags"]) == 1 + assert event_data["tags"][0] == "env:" + env def test_datatog_when_generate_event_data_with_with_missing_values_then_success(): @@ -39,18 +39,18 @@ def test_datatog_when_generate_event_data_with_with_missing_values_then_success( log = None email = None env = "test" - data_dog = DataDogWrapper(base_url="http://test.com", api_key='123key') + data_dog = DataDogWrapper(base_url="http://test.com", api_key="123key") # When - event_data = data_dog.generate_event_data(log=log, - email=email, - environment_name=env) + event_data = data_dog.generate_event_data( + log=log, email=email, environment_name=env + ) # Then expected_event_text = f"{log} by user {email}" - assert event_data['text'] == expected_event_text - assert len(event_data['tags']) == 1 - assert event_data['tags'][0] == f"env:{env}" + assert event_data["text"] == expected_event_text + assert len(event_data["tags"]) == 1 + assert event_data["tags"][0] == f"env:{env}" def test_datatog_when_generate_event_data_with_with_missing_env_then_success(): @@ -58,15 +58,15 @@ def test_datatog_when_generate_event_data_with_with_missing_env_then_success(): log = "some log data" email = "tes@email.com" env = None - data_dog = DataDogWrapper(base_url="http://test.com", api_key='123key') + data_dog = DataDogWrapper(base_url="http://test.com", api_key="123key") # When - event_data = data_dog.generate_event_data(log=log, - email=email, - environment_name=env) + event_data = data_dog.generate_event_data( + log=log, email=email, environment_name=env + ) # Then expected_event_text = f"{log} by user {email}" - assert event_data['text'] == expected_event_text - assert len(event_data['tags']) == 1 - assert event_data['tags'][0] == f"env:{env}" + assert event_data["text"] == expected_event_text + assert len(event_data["tags"]) == 1 + assert event_data["tags"][0] == f"env:{env}" diff --git a/src/integrations/datadog/tests/test_views.py b/src/integrations/datadog/tests/test_views.py index 65f2193f0f66..f9a9b0fac0ed 100644 --- a/src/integrations/datadog/tests/test_views.py +++ b/src/integrations/datadog/tests/test_views.py @@ -15,7 +15,6 @@ @pytest.mark.django_db class DatadogConfigurationTestCase(TestCase): - def setUp(self): self.client = APIClient() user = Helper.create_ffadminuser() @@ -32,14 +31,13 @@ def setUp(self): self.environment = Environment.objects.create( name="Test Environment", project=self.project ) - self.list_url = reverse('api-v1:projects:integrations-datadog-list', args=[self.project.id]) + self.list_url = reverse( + "api-v1:projects:integrations-datadog-list", args=[self.project.id] + ) def test_should_create_datadog_config_when_post(self): # Given setup data - data = { - 'base_url': 'http://test.com', - 'api_key': 'abc-123' - } + data = {"base_url": "http://test.com", "api_key": "abc-123"} # When response = self.client.post( @@ -55,13 +53,10 @@ def test_should_create_datadog_config_when_post(self): def test_should_return_BadRequest_when_duplicate_datadog_config_is_posted(self): # Given - config = DataDogConfiguration.objects.create(base_url="http://test.com", - api_key="api_123", - project=self.project) - data = { - 'base_url': 'http://test.com', - 'api_key': 'abc-123' - } + config = DataDogConfiguration.objects.create( + base_url="http://test.com", api_key="api_123", project=self.project + ) + data = {"base_url": "http://test.com", "api_key": "abc-123"} # When response = self.client.post( @@ -77,17 +72,17 @@ def test_should_return_BadRequest_when_duplicate_datadog_config_is_posted(self): # def test_should_update_configuration_when_put(self): # Given - config = DataDogConfiguration.objects.create(base_url="http://test.com", - api_key="api_123", - project=self.project) + config = DataDogConfiguration.objects.create( + base_url="http://test.com", api_key="api_123", project=self.project + ) api_key_updated = "new api" - data = { - 'base_url': config.base_url, - 'api_key': api_key_updated - } + data = {"base_url": config.base_url, "api_key": api_key_updated} # When - url = reverse('api-v1:projects:integrations-datadog-detail', args=[self.project.id, config.id]) + url = reverse( + "api-v1:projects:integrations-datadog-detail", + args=[self.project.id, config.id], + ) response = self.client.put( url, data=json.dumps(data), @@ -110,11 +105,14 @@ def test_should_return_datadog_config_list_when_requested(self): def test_should_remove_configuration_when_delete(self): # Given - config = DataDogConfiguration.objects.create(base_url="http://test.com", - api_key="api_123", - project=self.project) + config = DataDogConfiguration.objects.create( + base_url="http://test.com", api_key="api_123", project=self.project + ) # When - url = reverse('api-v1:projects:integrations-datadog-detail', args=[self.project.id, config.id]) + url = reverse( + "api-v1:projects:integrations-datadog-detail", + args=[self.project.id, config.id], + ) res = self.client.delete(url) # Then diff --git a/src/integrations/datadog/views.py b/src/integrations/datadog/views.py index 7d25557625df..85bb1dbac01e 100644 --- a/src/integrations/datadog/views.py +++ b/src/integrations/datadog/views.py @@ -11,14 +11,18 @@ class DataDogConfigurationViewSet(viewsets.ModelViewSet): pagination_class = None # set here to ensure documentation is correct def get_queryset(self): - project = get_object_or_404(self.request.user.get_permitted_projects(['VIEW_PROJECT']), - pk=self.kwargs['project_pk']) + project = get_object_or_404( + self.request.user.get_permitted_projects(["VIEW_PROJECT"]), + pk=self.kwargs["project_pk"], + ) return DataDogConfiguration.objects.filter(project=project) def perform_create(self, serializer): project_id = self.kwargs["project_pk"] if DataDogConfiguration.objects.filter(project_id=project_id).exists(): - raise ValidationError("DataDogConfiguration for this project already exist.") + raise ValidationError( + "DataDogConfiguration for this project already exist." + ) serializer.save(project_id=project_id) diff --git a/src/organisations/__init__.py b/src/organisations/__init__.py index 40512c675cb5..33acf6939e18 100644 --- a/src/organisations/__init__.py +++ b/src/organisations/__init__.py @@ -1 +1 @@ -default_app_config = 'organisations.apps.OrganisationsConfig' \ No newline at end of file +default_app_config = "organisations.apps.OrganisationsConfig" diff --git a/src/organisations/admin.py b/src/organisations/admin.py index 0dfc916c05f9..7e8d8fdb0744 100644 --- a/src/organisations/admin.py +++ b/src/organisations/admin.py @@ -3,8 +3,8 @@ from django.contrib import admin -from projects.models import Project from organisations.models import Organisation, Subscription +from projects.models import Project from users.models import FFAdminUser @@ -18,7 +18,7 @@ class SubscriptionInline(admin.StackedInline): model = Subscription extra = 0 show_change_link = True - verbose_name_plural = 'Subscription' + verbose_name_plural = "Subscription" class UserInline(admin.TabularInline): @@ -29,7 +29,10 @@ class UserInline(admin.TabularInline): @admin.register(Organisation) class OrganisationAdmin(admin.ModelAdmin): - inlines = [ProjectInline, SubscriptionInline,] - list_display = ('__str__', ) - list_filter = ('projects', 'subscription__plan') - search_fields = ('name', 'subscription__subscription_id') + inlines = [ + ProjectInline, + SubscriptionInline, + ] + list_display = ("__str__",) + list_filter = ("projects", "subscription__plan") + search_fields = ("name", "subscription__subscription_id") diff --git a/src/organisations/apps.py b/src/organisations/apps.py index c7fda5b321ca..811119c7f0ea 100644 --- a/src/organisations/apps.py +++ b/src/organisations/apps.py @@ -5,7 +5,7 @@ class OrganisationsConfig(AppConfig): - name = 'organisations' + name = "organisations" def ready(self): # noinspection PyUnresolvedReferences diff --git a/src/organisations/chargebee.py b/src/organisations/chargebee.py index b2a20e648680..8ebed5740ce5 100644 --- a/src/organisations/chargebee.py +++ b/src/organisations/chargebee.py @@ -15,11 +15,13 @@ def get_subscription_data_from_hosted_page(hosted_page_id): subscription = get_subscription_from_hosted_page(hosted_page) if subscription: return { - 'subscription_id': subscription.id, - 'plan': subscription.plan_id, - 'subscription_date': datetime.fromtimestamp(subscription.created_at, tz=UTC), - 'max_seats': get_max_seats_for_plan(subscription.plan_id), - 'customer_id': get_customer_id_from_hosted_page(hosted_page) + "subscription_id": subscription.id, + "plan": subscription.plan_id, + "subscription_date": datetime.fromtimestamp( + subscription.created_at, tz=UTC + ), + "max_seats": get_max_seats_for_plan(subscription.plan_id), + "customer_id": get_customer_id_from_hosted_page(hosted_page), } else: return {} @@ -31,27 +33,27 @@ def get_hosted_page(hosted_page_id): def get_subscription_from_hosted_page(hosted_page): - if hasattr(hosted_page, 'content'): + if hasattr(hosted_page, "content"): content = hosted_page.content - if hasattr(content, 'subscription'): + if hasattr(content, "subscription"): return content.subscription def get_customer_id_from_hosted_page(hosted_page): - if hasattr(hosted_page, 'content'): + if hasattr(hosted_page, "content"): content = hosted_page.content - if hasattr(content, 'customer'): + if hasattr(content, "customer"): return content.customer.id def get_max_seats_for_plan(plan_id): meta_data = get_plan_meta_data(plan_id) - return meta_data.get('seats', 1) + return meta_data.get("seats", 1) def get_plan_meta_data(plan_id): plan_details = get_plan_details(plan_id) - if plan_details and hasattr(plan_details.plan, 'meta_data'): + if plan_details and hasattr(plan_details.plan, "meta_data"): return plan_details.plan.meta_data or {} return {} @@ -62,17 +64,14 @@ def get_plan_details(plan_id): def get_portal_url(customer_id, redirect_url): - result = chargebee.PortalSession.create({ - 'redirect_url': redirect_url, - 'customer': { - 'id': customer_id - } - }) - if result and hasattr(result, 'portal_session'): + result = chargebee.PortalSession.create( + {"redirect_url": redirect_url, "customer": {"id": customer_id}} + ) + if result and hasattr(result, "portal_session"): return result.portal_session.access_url def get_customer_id_from_subscription_id(subscription_id): subscription_response = chargebee.Subscription.retrieve(subscription_id) - if hasattr(subscription_response, 'customer'): + if hasattr(subscription_response, "customer"): return subscription_response.customer.id diff --git a/src/organisations/management/commands/check_if_organisations_over_plan_limit.py b/src/organisations/management/commands/check_if_organisations_over_plan_limit.py index 4876632b4af9..ed9c11e9b54f 100644 --- a/src/organisations/management/commands/check_if_organisations_over_plan_limit.py +++ b/src/organisations/management/commands/check_if_organisations_over_plan_limit.py @@ -15,8 +15,13 @@ def handle(self, *args, **options): def send_alert(organisation): FFAdminUser.send_alert_to_admin_users( - subject='Organisation over number of seats', - message='Organisation %s has used %d seats which is over their plan limit of %d ' - '(plan: %s)' % (str(organisation.name), organisation.num_seats, organisation.subscription.max_seats, - organisation.subscription.plan) + subject="Organisation over number of seats", + message="Organisation %s has used %d seats which is over their plan limit of %d " + "(plan: %s)" + % ( + str(organisation.name), + organisation.num_seats, + organisation.subscription.max_seats, + organisation.subscription.plan, + ), ) diff --git a/src/organisations/models.py b/src/organisations/models.py index b54ec95dd96a..c2646112cd34 100644 --- a/src/organisations/models.py +++ b/src/organisations/models.py @@ -7,7 +7,11 @@ from django.utils import timezone from django.utils.encoding import python_2_unicode_compatible -from organisations.chargebee import get_max_seats_for_plan, get_portal_url, get_customer_id_from_subscription_id +from organisations.chargebee import ( + get_max_seats_for_plan, + get_portal_url, + get_customer_id_from_subscription_id, +) class OrganisationRole(enum.Enum): @@ -23,18 +27,28 @@ class Organisation(models.Model): name = models.CharField(max_length=2000) has_requested_features = models.BooleanField(default=False) webhook_notification_email = models.EmailField(null=True, blank=True) - created_date = models.DateTimeField('DateCreated', auto_now_add=True) + created_date = models.DateTimeField("DateCreated", auto_now_add=True) alerted_over_plan_limit = models.BooleanField(default=False) - stop_serving_flags = models.BooleanField(default=False, help_text='Enable this to cease serving flags for this ' - 'organisation.') - persist_trait_data = models.BooleanField(default=True, help_text='Disable this if you don\'t want Bullet Train ' - 'to store trait data for this org\'s identities.') - block_access_to_admin = models.BooleanField(default=False, help_text='Enable this to block all the access to admin ' - 'interface for the organisation') - feature_analytics = models.BooleanField(default=False, help_text='Record feature analytics in InfluxDB') + stop_serving_flags = models.BooleanField( + default=False, + help_text="Enable this to cease serving flags for this " "organisation.", + ) + persist_trait_data = models.BooleanField( + default=True, + help_text="Disable this if you don't want Bullet Train " + "to store trait data for this org's identities.", + ) + block_access_to_admin = models.BooleanField( + default=False, + help_text="Enable this to block all the access to admin " + "interface for the organisation", + ) + feature_analytics = models.BooleanField( + default=False, help_text="Record feature analytics in InfluxDB" + ) class Meta: - ordering = ['id'] + ordering = ["id"] def __str__(self): return "Org %s" % self.name @@ -48,10 +62,12 @@ def num_seats(self): return self.users.count() def has_subscription(self): - return hasattr(self, 'subscription') + return hasattr(self, "subscription") def over_plan_seats_limit(self): - return self.has_subscription() and 0 < self.subscription.max_seats < self.num_seats + return ( + self.has_subscription() and 0 < self.subscription.max_seats < self.num_seats + ) def reset_alert_status(self): self.alerted_over_plan_limit = False @@ -59,17 +75,22 @@ def reset_alert_status(self): class UserOrganisation(models.Model): - user = models.ForeignKey('users.FFAdminUser', on_delete=models.CASCADE) + user = models.ForeignKey("users.FFAdminUser", on_delete=models.CASCADE) organisation = models.ForeignKey(Organisation, on_delete=models.CASCADE) date_joined = models.DateTimeField(auto_now_add=True) role = models.CharField(max_length=50, choices=organisation_roles) class Meta: - unique_together = ('user', 'organisation',) + unique_together = ( + "user", + "organisation", + ) class Subscription(models.Model): - organisation = models.OneToOneField(Organisation, on_delete=models.CASCADE, related_name='subscription') + organisation = models.OneToOneField( + Organisation, on_delete=models.CASCADE, related_name="subscription" + ) subscription_id = models.CharField(max_length=100, blank=True, null=True) subscription_date = models.DateTimeField(blank=True, null=True) plan = models.CharField(max_length=20, null=True, blank=True) @@ -92,7 +113,9 @@ def get_portal_url(self, redirect_url): return None if not self.customer_id: - self.customer_id = get_customer_id_from_subscription_id(self.subscription_id) + self.customer_id = get_customer_id_from_subscription_id( + self.subscription_id + ) self.save() return get_portal_url(self.customer_id, redirect_url) @@ -101,4 +124,6 @@ class OrganisationWebhook(models.Model): url = models.URLField() name = models.CharField(max_length=100) enabled = models.BooleanField(default=True) - organisation = models.ForeignKey(Organisation, on_delete=models.CASCADE, related_name='webhooks') + organisation = models.ForeignKey( + Organisation, on_delete=models.CASCADE, related_name="webhooks" + ) diff --git a/src/organisations/permissions.py b/src/organisations/permissions.py index 74d57771de70..8e2b4b709c67 100644 --- a/src/organisations/permissions.py +++ b/src/organisations/permissions.py @@ -6,14 +6,18 @@ class NestedOrganisationEntityPermission(BasePermission): def has_permission(self, request, view): - organisation_pk = view.kwargs.get('organisation_pk') - if organisation_pk and request.user.is_admin(Organisation.objects.get(pk=organisation_pk)): + organisation_pk = view.kwargs.get("organisation_pk") + if organisation_pk and request.user.is_admin( + Organisation.objects.get(pk=organisation_pk) + ): return True - raise PermissionDenied('User does not have sufficient privileges to perform this action') + raise PermissionDenied( + "User does not have sufficient privileges to perform this action" + ) def has_object_permission(self, request, view, obj): - organisation_id = view.kwargs.get('organisation_pk') + organisation_id = view.kwargs.get("organisation_pk") organisation = Organisation.objects.get(id=organisation_id) if request.user.is_admin(organisation): @@ -30,24 +34,26 @@ def has_object_permission(self, request, view, obj): if request.user.is_admin(obj): return True - raise PermissionDenied('User does not have sufficient privileges to perform this action') + raise PermissionDenied( + "User does not have sufficient privileges to perform this action" + ) class OrganisationUsersPermission(BasePermission): def has_permission(self, request, view): - organisation_id = view.kwargs.get('organisation_pk') + organisation_id = view.kwargs.get("organisation_pk") organisation = Organisation.objects.get(id=organisation_id) if request.user.is_admin(organisation): return True - if view.action == 'list' and request.user.belongs_to(organisation.id): + if view.action == "list" and request.user.belongs_to(organisation.id): return True return False def has_object_permission(self, request, view, obj): - organisation_id = view.kwargs.get('organisation_pk') + organisation_id = view.kwargs.get("organisation_pk") organisation = Organisation.objects.get(id=organisation_id) if request.user.is_admin(organisation): @@ -58,17 +64,19 @@ def has_object_permission(self, request, view, obj): class UserPermissionGroupPermission(BasePermission): def has_permission(self, request, view): - organisation_pk = view.kwargs.get('organisation_pk') - if organisation_pk and request.user.is_admin(Organisation.objects.get(pk=organisation_pk)): + organisation_pk = view.kwargs.get("organisation_pk") + if organisation_pk and request.user.is_admin( + Organisation.objects.get(pk=organisation_pk) + ): return True - if view.action == 'list' and request.user.belongs_to(int(organisation_pk)): + if view.action == "list" and request.user.belongs_to(int(organisation_pk)): return True return False def has_object_permission(self, request, view, obj): - organisation_id = view.kwargs.get('organisation_pk') + organisation_id = view.kwargs.get("organisation_pk") organisation = Organisation.objects.get(id=organisation_id) if request.user.is_admin(organisation): diff --git a/src/organisations/serializers.py b/src/organisations/serializers.py index 1e25507cbdc4..e2d031715c95 100644 --- a/src/organisations/serializers.py +++ b/src/organisations/serializers.py @@ -4,8 +4,15 @@ from rest_framework import serializers from organisations.chargebee import get_subscription_data_from_hosted_page -from users.models import Invite, FFAdminUser -from .models import Organisation, Subscription, UserOrganisation, OrganisationRole, OrganisationWebhook +from users.models import FFAdminUser, Invite + +from .models import ( + Organisation, + OrganisationRole, + OrganisationWebhook, + Subscription, + UserOrganisation, +) logger = logging.getLogger(__name__) @@ -13,7 +20,7 @@ class SubscriptionSerializer(serializers.ModelSerializer): class Meta: model = Subscription - exclude = ('organisation',) + exclude = ("organisation",) class OrganisationSerializerFull(serializers.ModelSerializer): @@ -23,30 +30,35 @@ class OrganisationSerializerFull(serializers.ModelSerializer): class Meta: model = Organisation fields = ( - 'id', - 'name', - 'created_date', - 'webhook_notification_email', - 'num_seats', - 'subscription', - 'role', - 'persist_trait_data', - 'block_access_to_admin' + "id", + "name", + "created_date", + "webhook_notification_email", + "num_seats", + "subscription", + "role", + "persist_trait_data", + "block_access_to_admin", ) read_only_fields = ( - 'id', 'created_date', 'num_seats', 'role', 'persist_trait_data', 'block_access_to_admin' + "id", + "created_date", + "num_seats", + "role", + "persist_trait_data", + "block_access_to_admin", ) def get_role(self, instance): - if self.context.get('request'): - user = self.context['request'].user + if self.context.get("request"): + user = self.context["request"].user return user.get_organisation_role(instance) class OrganisationSerializerBasic(serializers.ModelSerializer): class Meta: model = Organisation - fields = ('id', 'name') + fields = ("id", "name") class UserOrganisationSerializer(serializers.ModelSerializer): @@ -54,31 +66,35 @@ class UserOrganisationSerializer(serializers.ModelSerializer): class Meta: model = UserOrganisation - fields = ('role', 'organisation') + fields = ("role", "organisation") class InviteSerializerFull(serializers.ModelSerializer): class InvitedBySerializer(serializers.ModelSerializer): class Meta: model = FFAdminUser - fields = ('id', 'email', 'first_name', 'last_name') + fields = ("id", "email", "first_name", "last_name") invited_by = InvitedBySerializer() class Meta: model = Invite - fields = ('id', 'email', 'role', 'date_created', 'invited_by') + fields = ("id", "email", "role", "date_created", "invited_by") class InviteSerializer(serializers.ModelSerializer): class Meta: model = Invite - fields = ('id', 'email', 'role', 'date_created') - read_only_fields = ('id', 'date_created') + fields = ("id", "email", "role", "date_created") + read_only_fields = ("id", "date_created") def validate(self, attrs): - if Invite.objects.filter(email=attrs['email'], organisation__id=self.context.get('organisation')).exists(): - raise serializers.ValidationError({'email': 'Invite for email %s already exists' % attrs['email']}) + if Invite.objects.filter( + email=attrs["email"], organisation__id=self.context.get("organisation") + ).exists(): + raise serializers.ValidationError( + {"email": "Invite for email %s already exists" % attrs["email"]} + ) return super(InviteSerializer, self).validate(attrs) @@ -91,22 +107,19 @@ def create(self, validated_data): organisation = self._get_organisation() user = self._get_invited_by() - invites = validated_data.get('invites', []) + invites = validated_data.get("invites", []) # for backwards compatibility, allow emails to be sent as a list of strings still - for email in validated_data.get('emails', []): - invites.append({ - 'email': email, - 'role': OrganisationRole.USER.name - }) + for email in validated_data.get("emails", []): + invites.append({"email": email, "role": OrganisationRole.USER.name}) created_invites = [] for invite in invites: data = { **invite, - 'invited_by': user, - 'organisation': organisation, - 'frontend_base_url': validated_data['frontend_base_url'] + "invited_by": user, + "organisation": organisation, + "frontend_base_url": validated_data["frontend_base_url"], } created_invites.append(Invite.objects.create(**data)) @@ -115,24 +128,26 @@ def create(self, validated_data): def to_representation(self, instance): # Return the invites in a dictionary since the serializer expects a single instance to be returned, not a list - return { - 'invites': [InviteSerializerFull(invite).data for invite in instance] - } + return {"invites": [InviteSerializerFull(invite).data for invite in instance]} def validate(self, attrs): - for email in attrs.get('emails', []): - if Invite.objects.filter(email=email, organisation__id=self.context.get('organisation')).exists(): - raise serializers.ValidationError({'emails': 'Invite for email %s already exists' % email}) + for email in attrs.get("emails", []): + if Invite.objects.filter( + email=email, organisation__id=self.context.get("organisation") + ).exists(): + raise serializers.ValidationError( + {"emails": "Invite for email %s already exists" % email} + ) return super(MultiInvitesSerializer, self).validate(attrs) def _get_invited_by(self): - return self.context.get('request').user if self.context.get('request') else None + return self.context.get("request").user if self.context.get("request") else None def _get_organisation(self): try: - return Organisation.objects.get(pk=self.context.get('organisation')) + return Organisation.objects.get(pk=self.context.get("organisation")) except Organisation.DoesNotExist: - raise serializers.ValidationError({'emails': 'Invalid organisation.'}) + raise serializers.ValidationError({"emails": "Invalid organisation."}) class UpdateSubscriptionSerializer(serializers.Serializer): @@ -145,15 +160,22 @@ def create(self, validated_data): organisation = self._get_organisation() if settings.ENABLE_CHARGEBEE: - subscription_data = get_subscription_data_from_hosted_page(hosted_page_id=validated_data['hosted_page_id']) + subscription_data = get_subscription_data_from_hosted_page( + hosted_page_id=validated_data["hosted_page_id"] + ) if subscription_data: - Subscription.objects.update_or_create(organisation=organisation, defaults=subscription_data) + Subscription.objects.update_or_create( + organisation=organisation, defaults=subscription_data + ) else: raise serializers.ValidationError( - {'detail': 'Couldn\'t get subscription information from hosted page.'}) + { + "detail": "Couldn't get subscription information from hosted page." + } + ) else: - logger.warning('Chargebee not configured. Not verifying hosted page.') + logger.warning("Chargebee not configured. Not verifying hosted page.") return organisation @@ -162,9 +184,9 @@ def update(self, instance, validated_data): def _get_organisation(self): try: - return Organisation.objects.get(pk=self.context.get('organisation')) + return Organisation.objects.get(pk=self.context.get("organisation")) except Organisation.DoesNotExist: - raise serializers.ValidationError('Invalid organisation.') + raise serializers.ValidationError("Invalid organisation.") class PortalUrlSerializer(serializers.Serializer): @@ -174,5 +196,5 @@ class PortalUrlSerializer(serializers.Serializer): class OrganisationWebhookSerializer(serializers.ModelSerializer): class Meta: model = OrganisationWebhook - fields = ('id', 'url', 'enabled') - read_only_fields = ('id',) + fields = ("id", "url", "enabled") + read_only_fields = ("id",) diff --git a/src/organisations/signals.py b/src/organisations/signals.py index 2d4888e8ec7c..ab7c90a4a0f2 100644 --- a/src/organisations/signals.py +++ b/src/organisations/signals.py @@ -14,11 +14,16 @@ def send_alert_if_cancelled(sender, instance, *args, **kwargs): except sender.DoesNotExist: return - if instance.cancellation_date and existing_object.cancellation_date != instance.cancellation_date: + if ( + instance.cancellation_date + and existing_object.cancellation_date != instance.cancellation_date + ): FFAdminUser.send_alert_to_admin_users( - subject='Organisation %s has cancelled their subscription' % instance.organisation.name, - message='Organisation %s has cancelled their subscription on %s' % (instance.organisation.name, - datetime.strftime( - instance.cancellation_date, - '%Y-%m-%d %H:%M')) + subject="Organisation %s has cancelled their subscription" + % instance.organisation.name, + message="Organisation %s has cancelled their subscription on %s" + % ( + instance.organisation.name, + datetime.strftime(instance.cancellation_date, "%Y-%m-%d %H:%M"), + ), ) diff --git a/src/organisations/tests/test_chargebee.py b/src/organisations/tests/test_chargebee.py index e12fc5726fcc..b9a95ad6ffcd 100644 --- a/src/organisations/tests/test_chargebee.py +++ b/src/organisations/tests/test_chargebee.py @@ -4,8 +4,12 @@ import pytest from pytz import UTC -from organisations.chargebee import get_max_seats_for_plan, get_subscription_data_from_hosted_page, get_portal_url, \ - get_customer_id_from_subscription_id +from organisations.chargebee import ( + get_customer_id_from_subscription_id, + get_max_seats_for_plan, + get_portal_url, + get_subscription_data_from_hosted_page, +) class MockChargeBeePlanResponse: @@ -16,36 +20,62 @@ def __init__(self, max_seats=0): class MockChargeBeePlan: def __init__(self, max_seats=0): - self.meta_data = { - "seats": max_seats - } + self.meta_data = {"seats": max_seats} class MockChargeBeeHostedPageResponse: - def __init__(self, subscription_id='subscription-id', plan_id='plan-id', created_at=datetime.utcnow(), - customer_id='customer-id'): - self.hosted_page = MockChargeBeeHostedPage(subscription_id=subscription_id, plan_id=plan_id, - created_at=created_at, customer_id=customer_id) + def __init__( + self, + subscription_id="subscription-id", + plan_id="plan-id", + created_at=datetime.utcnow(), + customer_id="customer-id", + ): + self.hosted_page = MockChargeBeeHostedPage( + subscription_id=subscription_id, + plan_id=plan_id, + created_at=created_at, + customer_id=customer_id, + ) class MockChargeBeeHostedPage: - def __init__(self, subscription_id, plan_id, created_at, customer_id, hosted_page_id='some-id'): + def __init__( + self, + subscription_id, + plan_id, + created_at, + customer_id, + hosted_page_id="some-id", + ): self.id = hosted_page_id - self.content = MockChargeBeeHostedPageContent(subscription_id=subscription_id, plan_id=plan_id, - created_at=created_at, customer_id=customer_id) + self.content = MockChargeBeeHostedPageContent( + subscription_id=subscription_id, + plan_id=plan_id, + created_at=created_at, + customer_id=customer_id, + ) class MockChargeBeeHostedPageContent: def __init__(self, subscription_id, plan_id, created_at, customer_id): - self.subscription = MockChargeBeeSubscription(subscription_id=subscription_id, plan_id=plan_id, - created_at=created_at) + self.subscription = MockChargeBeeSubscription( + subscription_id=subscription_id, plan_id=plan_id, created_at=created_at + ) self.customer = MockChargeBeeCustomer(customer_id) class MockChargeBeeSubscriptionResponse: - def __init__(self, subscription_id='subscription-id', plan_id='plan-id', created_at=datetime.now(), - customer_id='customer-id'): - self.subscription = MockChargeBeeSubscription(subscription_id, plan_id, created_at) + def __init__( + self, + subscription_id="subscription-id", + plan_id="plan-id", + created_at=datetime.now(), + customer_id="customer-id", + ): + self.subscription = MockChargeBeeSubscription( + subscription_id, plan_id, created_at + ) self.customer = MockChargeBeeCustomer(customer_id) @@ -62,7 +92,7 @@ def __init__(self, customer_id): class MockChargeBeePortalSessionResponse: - def __init__(self, access_url='https://test.portal.url'): + def __init__(self, access_url="https://test.portal.url"): self.portal_session = MockChargeBeePortalSession(access_url) @@ -73,13 +103,15 @@ def __init__(self, access_url): @pytest.mark.django_db class ChargeBeeTestCase(TestCase): - @mock.patch('organisations.chargebee.chargebee') + @mock.patch("organisations.chargebee.chargebee") def test_get_max_seats_for_plan_returns_max_seats_for_plan(self, mock_cb): # Given - plan_id = 'startup' + plan_id = "startup" expected_max_seats = 3 - mock_cb.Plan.retrieve.return_value = MockChargeBeePlanResponse(expected_max_seats) + mock_cb.Plan.retrieve.return_value = MockChargeBeePlanResponse( + expected_max_seats + ) # When max_seats = get_max_seats_for_plan(plan_id) @@ -87,52 +119,62 @@ def test_get_max_seats_for_plan_returns_max_seats_for_plan(self, mock_cb): # Then assert max_seats == expected_max_seats - @mock.patch('organisations.chargebee.chargebee') - def test_get_subscription_data_from_hosted_page_returns_expected_values(self, mock_cb): + @mock.patch("organisations.chargebee.chargebee") + def test_get_subscription_data_from_hosted_page_returns_expected_values( + self, mock_cb + ): # Given - subscription_id = 'abc123' - plan_id = 'startup' + subscription_id = "abc123" + plan_id = "startup" expected_max_seats = 3 created_at = datetime.now(tz=UTC) - customer_id = 'customer-id' - - mock_cb.HostedPage.retrieve.return_value = MockChargeBeeHostedPageResponse(subscription_id=subscription_id, - plan_id=plan_id, - created_at=created_at, - customer_id=customer_id) - mock_cb.Plan.retrieve.return_value = MockChargeBeePlanResponse(expected_max_seats) + customer_id = "customer-id" + + mock_cb.HostedPage.retrieve.return_value = MockChargeBeeHostedPageResponse( + subscription_id=subscription_id, + plan_id=plan_id, + created_at=created_at, + customer_id=customer_id, + ) + mock_cb.Plan.retrieve.return_value = MockChargeBeePlanResponse( + expected_max_seats + ) # When - subscription_data = get_subscription_data_from_hosted_page('hosted_page_id') + subscription_data = get_subscription_data_from_hosted_page("hosted_page_id") # Then - assert subscription_data['subscription_id'] == subscription_id - assert subscription_data['plan'] == plan_id - assert subscription_data['max_seats'] == expected_max_seats - assert subscription_data['subscription_date'] == created_at - assert subscription_data['customer_id'] == customer_id + assert subscription_data["subscription_id"] == subscription_id + assert subscription_data["plan"] == plan_id + assert subscription_data["max_seats"] == expected_max_seats + assert subscription_data["subscription_date"] == created_at + assert subscription_data["customer_id"] == customer_id - @mock.patch('organisations.chargebee.chargebee') + @mock.patch("organisations.chargebee.chargebee") def test_get_portal_url(self, mock_cb): # Given - access_url = 'https://test.url.com' + access_url = "https://test.url.com" - mock_cb.PortalSession.create.return_value = MockChargeBeePortalSessionResponse(access_url) + mock_cb.PortalSession.create.return_value = MockChargeBeePortalSessionResponse( + access_url + ) # When - portal_url = get_portal_url('some-customer-id', 'https://redirect.url.com') + portal_url = get_portal_url("some-customer-id", "https://redirect.url.com") # Then assert portal_url == access_url - @mock.patch('organisations.chargebee.chargebee') + @mock.patch("organisations.chargebee.chargebee") def test_get_customer_id_from_subscription(self, mock_cb): # Given - expected_customer_id = 'customer-id' - mock_cb.Subscription.retrieve.return_value = MockChargeBeeSubscriptionResponse(customer_id=expected_customer_id) + expected_customer_id = "customer-id" + mock_cb.Subscription.retrieve.return_value = MockChargeBeeSubscriptionResponse( + customer_id=expected_customer_id + ) # When - customer_id = get_customer_id_from_subscription_id('subscription-id') + customer_id = get_customer_id_from_subscription_id("subscription-id") # Then assert customer_id == expected_customer_id diff --git a/src/organisations/tests/test_models.py b/src/organisations/tests/test_models.py index 8ef9dd6cc1e4..b411b4893248 100644 --- a/src/organisations/tests/test_models.py +++ b/src/organisations/tests/test_models.py @@ -11,8 +11,10 @@ class OrganisationTestCase(TestCase): def test_can_create_organisation_with_and_without_webhook_notification_email(self): organisation_1 = Organisation.objects.create(name="Test org") - organisation_2 = Organisation.objects.create(name="Test org with webhook email", - webhook_notification_email="test@org.com") + organisation_2 = Organisation.objects.create( + name="Test org with webhook email", + webhook_notification_email="test@org.com", + ) self.assertTrue(organisation_1.name) self.assertTrue(organisation_2.name) @@ -20,7 +22,7 @@ def test_can_create_organisation_with_and_without_webhook_notification_email(sel class SubscriptionTestCase(TestCase): def setUp(self) -> None: - self.organisation = Organisation.objects.create(name='Test org') + self.organisation = Organisation.objects.create(name="Test org") def tearDown(self) -> None: Subscription.objects.all().delete() diff --git a/src/organisations/tests/test_permissions.py b/src/organisations/tests/test_permissions.py index 19c3e4553b4b..db3e0e2343bb 100644 --- a/src/organisations/tests/test_permissions.py +++ b/src/organisations/tests/test_permissions.py @@ -1,4 +1,4 @@ -from unittest import mock, TestCase +from unittest import TestCase, mock import pytest @@ -13,21 +13,21 @@ @pytest.mark.django_db class OrganisationUsersPermissionTestCase(TestCase): def setUp(self) -> None: - self.organisation = Organisation.objects.create(name='Test') - self.user = FFAdminUser.objects.create(email='user@test.com') - self.org_admin = FFAdminUser.objects.create(email='admin@test.com') + self.organisation = Organisation.objects.create(name="Test") + self.user = FFAdminUser.objects.create(email="user@test.com") + self.org_admin = FFAdminUser.objects.create(email="admin@test.com") self.user.add_organisation(self.organisation) self.org_admin.add_organisation(self.organisation, OrganisationRole.ADMIN) self.permissions = OrganisationUsersPermission() - mock_view.kwargs = {'organisation_pk': self.organisation.id} + mock_view.kwargs = {"organisation_pk": self.organisation.id} def test_org_user_can_list_users(self): # Given mock_request.user = self.user - mock_view.action = 'list' + mock_view.action = "list" # When result = self.permissions.has_permission(mock_request, mock_view) @@ -38,7 +38,7 @@ def test_org_user_can_list_users(self): def test_org_user_cannot_create_user(self): # Given mock_request.user = self.user - mock_view.action = 'create' + mock_view.action = "create" # When result = self.permissions.has_permission(mock_request, mock_view) diff --git a/src/organisations/tests/test_views.py b/src/organisations/tests/test_views.py index 2dddf6716233..d846d7985aff 100644 --- a/src/organisations/tests/test_views.py +++ b/src/organisations/tests/test_views.py @@ -35,29 +35,29 @@ def setUp(self): def test_should_return_organisation_list_when_requested(self): # Given - organisation = Organisation.objects.create(name='Test org') + organisation = Organisation.objects.create(name="Test org") self.user.add_organisation(organisation) # When - response = self.client.get('/api/v1/organisations/') + response = self.client.get("/api/v1/organisations/") # Then assert response.status_code == status.HTTP_200_OK - assert 'count' in response.data and response.data['count'] == 1 + assert "count" in response.data and response.data["count"] == 1 # and certain required fields are there response_json = response.json() - org_data = response_json['results'][0] - assert 'persist_trait_data' in org_data + org_data = response_json["results"][0] + assert "persist_trait_data" in org_data def test_should_create_new_organisation(self): # Given - org_name = 'Test create org' - webhook_notification_email = 'test@email.com' - url = reverse('api-v1:organisations:organisation-list') + org_name = "Test create org" + webhook_notification_email = "test@email.com" + url = reverse("api-v1:organisations:organisation-list") data = { - 'name': org_name, - 'webhook_notification_email': webhook_notification_email + "name": org_name, + "webhook_notification_email": webhook_notification_email, } # When @@ -65,7 +65,10 @@ def test_should_create_new_organisation(self): # Then assert response.status_code == status.HTTP_201_CREATED - assert Organisation.objects.get(name=org_name).webhook_notification_email == webhook_notification_email + assert ( + Organisation.objects.get(name=org_name).webhook_notification_email + == webhook_notification_email + ) def test_should_update_organisation_name(self): # Given @@ -73,10 +76,10 @@ def test_should_update_organisation_name(self): new_organisation_name = "new test org" organisation = Organisation.objects.create(name=original_organisation_name) self.user.add_organisation(organisation, OrganisationRole.ADMIN) - url = reverse('api-v1:organisations:organisation-detail', args=[organisation.pk]) - data = { - 'name': new_organisation_name - } + url = reverse( + "api-v1:organisations:organisation-detail", args=[organisation.pk] + ) + data = {"name": new_organisation_name} # When response = self.client.put(url, data=data) @@ -91,14 +94,18 @@ def test_should_invite_users(self): org_name = "test_org" organisation = Organisation.objects.create(name=org_name) self.user.add_organisation(organisation, OrganisationRole.ADMIN) - url = reverse('api-v1:organisations:organisation-invite', args=[organisation.pk]) + url = reverse( + "api-v1:organisations:organisation-invite", args=[organisation.pk] + ) data = { - 'emails': ['test@example.com'], - 'frontend_base_url': 'https://example.com' + "emails": ["test@example.com"], + "frontend_base_url": "https://example.com", } # When - response = self.client.post(url, data=json.dumps(data), content_type='application/json') + response = self.client.post( + url, data=json.dumps(data), content_type="application/json" + ) # Then assert response.status_code == status.HTTP_201_CREATED @@ -109,37 +116,50 @@ def test_should_fail_if_invite_exists_already(self): organisation = Organisation.objects.create(name="test org") self.user.add_organisation(organisation, OrganisationRole.ADMIN) email = "test_2@example.com" - data = { - 'emails': [email], - 'frontend_base_url': 'https://example.com' - } - url = reverse('api-v1:organisations:organisation-invite', args=[organisation.pk]) + data = {"emails": [email], "frontend_base_url": "https://example.com"} + url = reverse( + "api-v1:organisations:organisation-invite", args=[organisation.pk] + ) # When - response_success = self.client.post(url, data=json.dumps(data), content_type='application/json') - response_fail = self.client.post(url, data=json.dumps(data), content_type='application/json') + response_success = self.client.post( + url, data=json.dumps(data), content_type="application/json" + ) + response_fail = self.client.post( + url, data=json.dumps(data), content_type="application/json" + ) # Then assert response_success.status_code == status.HTTP_201_CREATED assert response_fail.status_code == status.HTTP_400_BAD_REQUEST - assert Invite.objects.filter(email=email, organisation=organisation).count() == 1 + assert ( + Invite.objects.filter(email=email, organisation=organisation).count() == 1 + ) def test_should_return_all_invites_and_can_resend(self): # Given organisation = Organisation.objects.create(name="Test org 2") self.user.add_organisation(organisation, OrganisationRole.ADMIN) - invite_1 = Invite.objects.create(email="test_1@example.com", - frontend_base_url="https://www.example.com", - organisation=organisation) - invite_2 = Invite.objects.create(email="test_2@example.com", - frontend_base_url="https://www.example.com", - organisation=organisation) + invite_1 = Invite.objects.create( + email="test_1@example.com", + frontend_base_url="https://www.example.com", + organisation=organisation, + ) + invite_2 = Invite.objects.create( + email="test_2@example.com", + frontend_base_url="https://www.example.com", + organisation=organisation, + ) # When - invite_list_response = self.client.get('/api/v1/organisations/%s/invites/' % organisation.id) - invite_resend_response = self.client.post('/api/v1/organisations/%s/invites/%s/resend/' % ( - organisation.id, invite_1.id)) + invite_list_response = self.client.get( + "/api/v1/organisations/%s/invites/" % organisation.id + ) + invite_resend_response = self.client.post( + "/api/v1/organisations/%s/invites/%s/resend/" + % (organisation.id, invite_1.id) + ) # Then assert invite_list_response.status_code == status.HTTP_200_OK @@ -147,20 +167,22 @@ def test_should_return_all_invites_and_can_resend(self): def test_can_remove_a_user_from_an_organisation(self): # Given - organisation = Organisation.objects.create(name='Test org') + organisation = Organisation.objects.create(name="Test org") self.user.add_organisation(organisation, OrganisationRole.ADMIN) - user_2 = FFAdminUser.objects.create(email='test@example.com') + user_2 = FFAdminUser.objects.create(email="test@example.com") user_2.add_organisation(organisation) - url = reverse('api-v1:organisations:organisation-remove-users', args=[organisation.pk]) + url = reverse( + "api-v1:organisations:organisation-remove-users", args=[organisation.pk] + ) - data = [ - {'id': user_2.pk} - ] + data = [{"id": user_2.pk}] # When - res = self.client.post(url, data=json.dumps(data), content_type='application/json') + res = self.client.post( + url, data=json.dumps(data), content_type="application/json" + ) # Then assert res.status_code == status.HTTP_200_OK @@ -168,57 +190,61 @@ def test_can_remove_a_user_from_an_organisation(self): def test_can_invite_user_as_admin(self): # Given - organisation = Organisation.objects.create(name='Test org') + organisation = Organisation.objects.create(name="Test org") self.user.add_organisation(organisation, OrganisationRole.ADMIN) - url = reverse('api-v1:organisations:organisation-invite', args=[organisation.pk]) - invited_email = 'test@example.com' + url = reverse( + "api-v1:organisations:organisation-invite", args=[organisation.pk] + ) + invited_email = "test@example.com" data = { - 'invites': [{ - 'email': invited_email, - 'role': OrganisationRole.ADMIN.name - }], - 'frontend_base_url': 'http://blah.com' + "invites": [{"email": invited_email, "role": OrganisationRole.ADMIN.name}], + "frontend_base_url": "http://blah.com", } # When - self.client.post(url, data=json.dumps(data), content_type='application/json') + self.client.post(url, data=json.dumps(data), content_type="application/json") # Then assert Invite.objects.filter(email=invited_email).exists() # and - assert Invite.objects.get(email=invited_email).role == OrganisationRole.ADMIN.name + assert ( + Invite.objects.get(email=invited_email).role == OrganisationRole.ADMIN.name + ) def test_can_invite_user_as_user(self): # Given - organisation = Organisation.objects.create(name='Test org') + organisation = Organisation.objects.create(name="Test org") self.user.add_organisation(organisation, OrganisationRole.ADMIN) - url = reverse('api-v1:organisations:organisation-invite', args=[organisation.pk]) - invited_email = 'test@example.com' + url = reverse( + "api-v1:organisations:organisation-invite", args=[organisation.pk] + ) + invited_email = "test@example.com" data = { - 'invites': [{ - 'email': invited_email, - 'role': OrganisationRole.USER.name - }], - 'frontend_base_url': 'http://blah.com' + "invites": [{"email": invited_email, "role": OrganisationRole.USER.name}], + "frontend_base_url": "http://blah.com", } # When - self.client.post(url, data=json.dumps(data), content_type='application/json') + self.client.post(url, data=json.dumps(data), content_type="application/json") # Then assert Invite.objects.filter(email=invited_email).exists() # and - assert Invite.objects.get(email=invited_email).role == OrganisationRole.USER.name + assert ( + Invite.objects.get(email=invited_email).role == OrganisationRole.USER.name + ) def test_user_can_get_projects_for_an_organisation(self): # Given - organisation = Organisation.objects.create(name='Test org') + organisation = Organisation.objects.create(name="Test org") self.user.add_organisation(organisation, OrganisationRole.USER) - url = reverse('api-v1:organisations:organisation-projects', args=[organisation.pk]) + url = reverse( + "api-v1:organisations:organisation-projects", args=[organisation.pk] + ) # When res = self.client.get(url) @@ -232,47 +258,54 @@ def test_should_get_usage_for_organisation(self, mock_influxdb_client): org_name = "test_org" organisation = Organisation.objects.create(name=org_name) self.user.add_organisation(organisation, OrganisationRole.ADMIN) - url = reverse('api-v1:organisations:organisation-usage', args=[organisation.pk]) + url = reverse("api-v1:organisations:organisation-usage", args=[organisation.pk]) influx_org = settings.INFLUXDB_ORG read_bucket = settings.INFLUXDB_BUCKET + "_downsampled_15m" - query = f'from(bucket:"{read_bucket}") ' \ - f'|> range(start: -30d, stop: now()) ' \ - f'|> filter(fn:(r) => r._measurement == "api_call") ' \ - f'|> filter(fn: (r) => r["_field"] == "request_count") ' \ - f'|> filter(fn: (r) => r["organisation_id"] == "{organisation.id}") ' \ - f'|> drop(columns: ["organisation", "project", "project_id"])' \ - f'|> sum()' + query = ( + f'from(bucket:"{read_bucket}") ' + f"|> range(start: -30d, stop: now()) " + f'|> filter(fn:(r) => r._measurement == "api_call") ' + f'|> filter(fn: (r) => r["_field"] == "request_count") ' + f'|> filter(fn: (r) => r["organisation_id"] == "{organisation.id}") ' + f'|> drop(columns: ["organisation", "project", "project_id"])' + f"|> sum()" + ) # When - response = self.client.get(url, content_type='application/json') + response = self.client.get(url, content_type="application/json") # Then assert response.status_code == status.HTTP_200_OK - mock_influxdb_client.query_api.return_value.query.assert_called_once_with(org=influx_org, query=query) + mock_influxdb_client.query_api.return_value.query.assert_called_once_with( + org=influx_org, query=query + ) @override_settings(ENABLE_CHARGEBEE=True) - @mock.patch('organisations.serializers.get_subscription_data_from_hosted_page') - def test_update_subscription_gets_subscription_data_from_chargebee(self, mock_get_subscription_data): + @mock.patch("organisations.serializers.get_subscription_data_from_hosted_page") + def test_update_subscription_gets_subscription_data_from_chargebee( + self, mock_get_subscription_data + ): # Given - organisation = Organisation.objects.create(name='Test org') + organisation = Organisation.objects.create(name="Test org") self.user.add_organisation(organisation, OrganisationRole.ADMIN) - url = reverse('api-v1:organisations:organisation-update-subscription', args=[organisation.pk]) + url = reverse( + "api-v1:organisations:organisation-update-subscription", + args=[organisation.pk], + ) - hosted_page_id = 'some-id' - data = { - 'hosted_page_id': hosted_page_id - } + hosted_page_id = "some-id" + data = {"hosted_page_id": hosted_page_id} - customer_id = 'customer-id' + customer_id = "customer-id" - subscription_id = 'subscription-id' + subscription_id = "subscription-id" mock_get_subscription_data.return_value = { - 'subscription_id': subscription_id, - 'plan': 'plan-id', - 'max_seats': 3, - 'subscription_date': datetime.now(tz=UTC), - 'customer_id': customer_id + "subscription_id": subscription_id, + "plan": "plan-id", + "max_seats": 3, + "subscription_date": datetime.now(tz=UTC), + "customer_id": customer_id, } # When @@ -285,20 +318,29 @@ def test_update_subscription_gets_subscription_data_from_chargebee(self, mock_ge mock_get_subscription_data.assert_called_with(hosted_page_id=hosted_page_id) # and - assert organisation.has_subscription() and organisation.subscription.subscription_id == subscription_id and \ - organisation.subscription.customer_id == customer_id + assert ( + organisation.has_subscription() + and organisation.subscription.subscription_id == subscription_id + and organisation.subscription.customer_id == customer_id + ) def test_delete_organisation(self): # GIVEN an organisation with a project, environment, feature, segment and feature segment organisation = Organisation.objects.create(name="Test organisation") self.user.add_organisation(organisation, OrganisationRole.ADMIN) project = Project.objects.create(name="Test project", organisation=organisation) - environment = Environment.objects.create(name="Test environment", project=project) + environment = Environment.objects.create( + name="Test environment", project=project + ) feature = Feature.objects.create(name="Test feature", project=project) segment = Segment.objects.create(name="Test segment", project=project) - FeatureSegment.objects.create(feature=feature, segment=segment, environment=environment) + FeatureSegment.objects.create( + feature=feature, segment=segment, environment=environment + ) - delete_organisation_url = reverse("api-v1:organisations:organisation-detail", args=[organisation.id]) + delete_organisation_url = reverse( + "api-v1:organisations:organisation-detail", args=[organisation.id] + ) # WHEN response = self.client.delete(delete_organisation_url) @@ -311,38 +353,49 @@ def test_delete_organisation(self): class ChargeBeeWebhookTestCase(TestCase): def setUp(self) -> None: self.client = APIClient() - self.cb_user = User.objects.create(email='chargebee@bullet-train.io', username='chargebee') - self.admin_user = User.objects.create(email='admin@bullet-train.io', username='admin', is_staff=True) + self.cb_user = User.objects.create( + email="chargebee@bullet-train.io", username="chargebee" + ) + self.admin_user = User.objects.create( + email="admin@bullet-train.io", username="admin", is_staff=True + ) self.client.force_authenticate(self.cb_user) - self.organisation = Organisation.objects.create(name='Test org') + self.organisation = Organisation.objects.create(name="Test org") - self.url = reverse('api-v1:chargebee-webhook') - self.subscription_id = 'subscription-id' - self.old_plan_id = 'old-plan-id' + self.url = reverse("api-v1:chargebee-webhook") + self.subscription_id = "subscription-id" + self.old_plan_id = "old-plan-id" self.old_max_seats = 1 - self.subscription = Subscription.objects.create(organisation=self.organisation, - subscription_id=self.subscription_id, - plan=self.old_plan_id, max_seats=self.old_max_seats) - - @mock.patch('organisations.models.get_max_seats_for_plan') - def test_when_subscription_plan_is_changed_max_seats_updated(self, mock_get_max_seats): + self.subscription = Subscription.objects.create( + organisation=self.organisation, + subscription_id=self.subscription_id, + plan=self.old_plan_id, + max_seats=self.old_max_seats, + ) + + @mock.patch("organisations.models.get_max_seats_for_plan") + def test_when_subscription_plan_is_changed_max_seats_updated( + self, mock_get_max_seats + ): # Given - new_plan_id = 'new-plan-id' + new_plan_id = "new-plan-id" new_max_seats = 3 mock_get_max_seats.return_value = new_max_seats data = { - 'content': { - 'subscription': { - 'status': 'active', - 'id': self.subscription_id, - 'plan_id': new_plan_id + "content": { + "subscription": { + "status": "active", + "id": self.subscription_id, + "plan_id": new_plan_id, } } } # When - res = self.client.post(self.url, data=json.dumps(data), content_type='application/json') + res = self.client.post( + self.url, data=json.dumps(data), content_type="application/json" + ) # Then assert res.status_code == status.HTTP_200_OK @@ -352,21 +405,25 @@ def test_when_subscription_plan_is_changed_max_seats_updated(self, mock_get_max_ assert self.subscription.plan == new_plan_id assert self.subscription.max_seats == new_max_seats - def test_when_subscription_is_set_to_non_renewing_then_cancellation_date_set_and_alert_sent(self): + def test_when_subscription_is_set_to_non_renewing_then_cancellation_date_set_and_alert_sent( + self, + ): # Given cancellation_date = datetime.now(tz=UTC) + timedelta(days=1) data = { - 'content': { - 'subscription': { - 'status': 'non_renewing', - 'id': self.subscription_id, - 'current_term_end': datetime.timestamp(cancellation_date) + "content": { + "subscription": { + "status": "non_renewing", + "id": self.subscription_id, + "current_term_end": datetime.timestamp(cancellation_date), } } } # When - self.client.post(self.url, data=json.dumps(data), content_type='application/json') + self.client.post( + self.url, data=json.dumps(data), content_type="application/json" + ) # Then self.subscription.refresh_from_db() @@ -375,21 +432,25 @@ def test_when_subscription_is_set_to_non_renewing_then_cancellation_date_set_and # and assert len(mail.outbox) == 1 - def test_when_subscription_is_cancelled_then_cancellation_date_set_and_alert_sent(self): + def test_when_subscription_is_cancelled_then_cancellation_date_set_and_alert_sent( + self, + ): # Given cancellation_date = datetime.now(tz=UTC) + timedelta(days=1) data = { - 'content': { - 'subscription': { - 'status': 'cancelled', - 'id': self.subscription_id, - 'current_term_end': datetime.timestamp(cancellation_date) + "content": { + "subscription": { + "status": "cancelled", + "id": self.subscription_id, + "current_term_end": datetime.timestamp(cancellation_date), } } } # When - self.client.post(self.url, data=json.dumps(data), content_type='application/json') + self.client.post( + self.url, data=json.dumps(data), content_type="application/json" + ) # Then self.subscription.refresh_from_db() @@ -398,23 +459,27 @@ def test_when_subscription_is_cancelled_then_cancellation_date_set_and_alert_sen # and assert len(mail.outbox) == 1 - def test_when_cancelled_subscription_is_renewed_then_subscription_activated_and_no_cancellation_email_sent(self): + def test_when_cancelled_subscription_is_renewed_then_subscription_activated_and_no_cancellation_email_sent( + self, + ): # Given self.subscription.cancellation_date = datetime.now(tz=UTC) - timedelta(days=1) self.subscription.save() mail.outbox.clear() data = { - 'content': { - 'subscription': { - 'status': 'active', - 'id': self.subscription_id, + "content": { + "subscription": { + "status": "active", + "id": self.subscription_id, } } } # When - self.client.post(self.url, data=json.dumps(data), content_type='application/json') + self.client.post( + self.url, data=json.dumps(data), content_type="application/json" + ) # Then self.subscription.refresh_from_db() @@ -423,19 +488,18 @@ def test_when_cancelled_subscription_is_renewed_then_subscription_activated_and_ # and assert not mail.outbox - def test_when_chargebee_webhook_received_with_unknown_subscription_id_then_404(self): + def test_when_chargebee_webhook_received_with_unknown_subscription_id_then_404( + self, + ): # Given data = { - 'content': { - 'subscription': { - 'status': 'active', - 'id': 'some-random-id' - } - } + "content": {"subscription": {"status": "active", "id": "some-random-id"}} } # When - res = self.client.post(self.url, data=json.dumps(data), content_type='application/json') + res = self.client.post( + self.url, data=json.dumps(data), content_type="application/json" + ) # Then assert res.status_code == status.HTTP_400_BAD_REQUEST @@ -444,17 +508,20 @@ def test_when_chargebee_webhook_received_with_unknown_subscription_id_then_404(s @pytest.mark.django_db class OrganisationWebhookViewSetTestCase(TestCase): def setUp(self) -> None: - self.organisation = Organisation.objects.create(name='Test org') - self.user = FFAdminUser.objects.create(email='test@test.com') + self.organisation = Organisation.objects.create(name="Test org") + self.user = FFAdminUser.objects.create(email="test@test.com") self.user.add_organisation(self.organisation, OrganisationRole.ADMIN) self.client = APIClient() self.client.force_authenticate(self.user) - self.list_url = reverse('api-v1:organisations:organisation-webhooks-list', args=[self.organisation.id]) + self.list_url = reverse( + "api-v1:organisations:organisation-webhooks-list", + args=[self.organisation.id], + ) def test_user_can_create_new_webhook(self): # Given data = { - 'url': 'https://test.com/my-webhook', + "url": "https://test.com/my-webhook", } # When diff --git a/src/organisations/urls.py b/src/organisations/urls.py index ff2ce7dd4de4..f1700c146201 100644 --- a/src/organisations/urls.py +++ b/src/organisations/urls.py @@ -1,22 +1,31 @@ -from django.conf.urls import url, include +from django.conf.urls import include, url from rest_framework_nested import routers from organisations.views import InviteViewSet, OrganisationWebhookViewSet from users.views import FFAdminUserViewSet, UserPermissionGroupViewSet + from . import views router = routers.DefaultRouter() -router.register(r'', views.OrganisationViewSet, basename='organisation') +router.register(r"", views.OrganisationViewSet, basename="organisation") -organisations_router = routers.NestedSimpleRouter(router, r'', lookup='organisation') -organisations_router.register(r'invites', InviteViewSet, basename='organisation-invites') -organisations_router.register(r'users', FFAdminUserViewSet, basename='organisation-users') -organisations_router.register(r'groups', UserPermissionGroupViewSet, basename='organisation-groups') -organisations_router.register(r'webhooks', OrganisationWebhookViewSet, basename='organisation-webhooks') +organisations_router = routers.NestedSimpleRouter(router, r"", lookup="organisation") +organisations_router.register( + r"invites", InviteViewSet, basename="organisation-invites" +) +organisations_router.register( + r"users", FFAdminUserViewSet, basename="organisation-users" +) +organisations_router.register( + r"groups", UserPermissionGroupViewSet, basename="organisation-groups" +) +organisations_router.register( + r"webhooks", OrganisationWebhookViewSet, basename="organisation-webhooks" +) -app_name = 'organisations' +app_name = "organisations" urlpatterns = [ - url(r'^', include(router.urls)), - url(r'^', include(organisations_router.urls)), + url(r"^", include(router.urls)), + url(r"^", include(organisations_router.urls)), ] diff --git a/src/organisations/views.py b/src/organisations/views.py index 80db5e7d2f79..d18f52767111 100644 --- a/src/organisations/views.py +++ b/src/organisations/views.py @@ -6,7 +6,7 @@ from django.contrib.sites.shortcuts import get_current_site from drf_yasg2.utils import swagger_auto_schema -from rest_framework import viewsets, status +from rest_framework import status, viewsets from rest_framework.authentication import BasicAuthentication from rest_framework.decorators import action, api_view, authentication_classes from rest_framework.exceptions import ValidationError @@ -14,10 +14,22 @@ from rest_framework.response import Response from analytics.influxdb_wrapper import get_events_for_organisation -from organisations.models import OrganisationRole, Subscription, OrganisationWebhook -from organisations.permissions import OrganisationPermission, NestedOrganisationEntityPermission -from organisations.serializers import OrganisationSerializerFull, MultiInvitesSerializer, UpdateSubscriptionSerializer, \ - PortalUrlSerializer, OrganisationWebhookSerializer +from organisations.models import ( + OrganisationRole, + OrganisationWebhook, + Subscription, +) +from organisations.permissions import ( + NestedOrganisationEntityPermission, + OrganisationPermission, +) +from organisations.serializers import ( + MultiInvitesSerializer, + OrganisationSerializerFull, + OrganisationWebhookSerializer, + PortalUrlSerializer, + UpdateSubscriptionSerializer, +) from projects.serializers import ProjectSerializer from users.models import Invite from users.serializers import InviteListSerializer, UserIdSerializer @@ -30,20 +42,20 @@ class OrganisationViewSet(viewsets.ModelViewSet): permission_classes = (IsAuthenticated, OrganisationPermission) def get_serializer_class(self): - if self.action == 'remove_users': + if self.action == "remove_users": return UserIdSerializer - elif self.action == 'invite': + elif self.action == "invite": return MultiInvitesSerializer - elif self.action == 'update_subscription': + elif self.action == "update_subscription": return UpdateSubscriptionSerializer - elif self.action == 'get_portal_url': + elif self.action == "get_portal_url": return PortalUrlSerializer return OrganisationSerializerFull def get_serializer_context(self): context = super(OrganisationViewSet, self).get_serializer_context() - if self.action in ('remove_users', 'invite', 'update_subscription'): - context['organisation'] = self.kwargs.get('pk') + if self.action in ("remove_users", "invite", "update_subscription"): + context["organisation"] = self.kwargs.get("pk") return context def get_queryset(self): @@ -76,9 +88,9 @@ def invite(self, request, pk): serializer.save() # serializer returns a dictionary containing the list of serialized invite objects since it's a single # serializer generating multiple instances. - return Response(serializer.data.get('invites'), status=status.HTTP_201_CREATED) + return Response(serializer.data.get("invites"), status=status.HTTP_201_CREATED) - @action(detail=True, methods=['POST'], url_path='remove-users') + @action(detail=True, methods=["POST"], url_path="remove-users") def remove_users(self, request, pk): """ Takes a list of users and removes them from the organisation provided in the url @@ -97,30 +109,40 @@ def usage(self, request, pk): except (TypeError, ValueError): # TypeError can be thrown when getting service account if not configured # ValueError can be thrown if GA returns a value that cannot be converted to integer - return Response({"error": "Couldn't get number of events for organisation."}, - status=status.HTTP_500_INTERNAL_SERVER_ERROR) + return Response( + {"error": "Couldn't get number of events for organisation."}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) return Response({"events": events}, status=status.HTTP_200_OK) - @action(detail=True, methods=['POST'], url_path='update-subscription') + @action(detail=True, methods=["POST"], url_path="update-subscription") @swagger_auto_schema(responses={200: OrganisationSerializerFull}) def update_subscription(self, request, pk): serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) serializer.save() - return Response(OrganisationSerializerFull(instance=self.get_object()).data, status=status.HTTP_200_OK) + return Response( + OrganisationSerializerFull(instance=self.get_object()).data, + status=status.HTTP_200_OK, + ) - @action(detail=True, methods=['GET'], url_path='portal-url') + @action(detail=True, methods=["GET"], url_path="portal-url") def get_portal_url(self, request, pk): organisation = self.get_object() if not organisation.has_subscription(): - return Response({'detail': 'Organisation has no subscription'}, status=status.HTTP_400_BAD_REQUEST) + return Response( + {"detail": "Organisation has no subscription"}, + status=status.HTTP_400_BAD_REQUEST, + ) redirect_url = get_current_site(request) - serializer = self.get_serializer(data={'url': organisation.subscription.get_portal_url(redirect_url)}) + serializer = self.get_serializer( + data={"url": organisation.subscription.get_portal_url(redirect_url)} + ) serializer.is_valid(raise_exception=True) return Response(serializer.data) - @action(detail=True, methods=['GET'], url_path='influx-data') + @action(detail=True, methods=["GET"], url_path="influx-data") def get_influx_data(self, request, pk): event_list = get_multiple_event_list_for_organisation(pk) @@ -132,8 +154,10 @@ class InviteViewSet(viewsets.ModelViewSet): permission_classes = (IsAuthenticated, NestedOrganisationEntityPermission) def get_queryset(self): - organisation_pk = self.kwargs.get('organisation_pk') - if int(organisation_pk) not in [org.id for org in self.request.user.organisations.all()]: + organisation_pk = self.kwargs.get("organisation_pk") + if int(organisation_pk) not in [ + org.id for org in self.request.user.organisations.all() + ]: return [] return Invite.objects.filter(organisation__id=organisation_pk) @@ -144,7 +168,7 @@ def resend(self, request, organisation_pk, pk): return Response(status=status.HTTP_200_OK) -@api_view(['POST']) +@api_view(["POST"]) @authentication_classes([BasicAuthentication]) def chargebee_webhook(request): """ @@ -157,39 +181,49 @@ def chargebee_webhook(request): send alert to admin users. """ - if request.data.get('content') and 'subscription' in request.data.get('content'): - subscription_data = request.data['content']['subscription'] + if request.data.get("content") and "subscription" in request.data.get("content"): + subscription_data = request.data["content"]["subscription"] try: - existing_subscription = Subscription.objects.get(subscription_id=subscription_data.get('id')) + existing_subscription = Subscription.objects.get( + subscription_id=subscription_data.get("id") + ) except (Subscription.DoesNotExist, Subscription.MultipleObjectsReturned): - error_message = 'Couldn\'t get unique subscription for ChargeBee id %s' % subscription_data.get('id') + error_message = ( + "Couldn't get unique subscription for ChargeBee id %s" + % subscription_data.get("id") + ) logger.error(error_message) return Response(data=error_message, status=status.HTTP_400_BAD_REQUEST) - subscription_status = subscription_data.get('status') - if subscription_status == 'active': - if subscription_data.get('plan_id') != existing_subscription.plan: - existing_subscription.update_plan(subscription_data.get('plan_id')) - elif subscription_status in ('non_renewing', 'cancelled'): - existing_subscription.cancel(datetime.fromtimestamp(subscription_data.get('current_term_end'))) + subscription_status = subscription_data.get("status") + if subscription_status == "active": + if subscription_data.get("plan_id") != existing_subscription.plan: + existing_subscription.update_plan(subscription_data.get("plan_id")) + elif subscription_status in ("non_renewing", "cancelled"): + existing_subscription.cancel( + datetime.fromtimestamp(subscription_data.get("current_term_end")) + ) return Response(status=status.HTTP_200_OK) + class OrganisationWebhookViewSet(viewsets.ModelViewSet): serializer_class = OrganisationWebhookSerializer permission_classes = [IsAuthenticated, NestedOrganisationEntityPermission] def get_queryset(self): - if 'organisation_pk' not in self.kwargs: + if "organisation_pk" not in self.kwargs: raise ValidationError("Missing required path parameter 'organisation_pk'") - return OrganisationWebhook.objects.filter(organisation_id=self.kwargs['organisation_pk']) + return OrganisationWebhook.objects.filter( + organisation_id=self.kwargs["organisation_pk"] + ) def perform_update(self, serializer): - organisation_id = self.kwargs['organisation_pk'] + organisation_id = self.kwargs["organisation_pk"] serializer.save(organisation_id=organisation_id) def perform_create(self, serializer): - organisation_id = self.kwargs['organisation_pk'] + organisation_id = self.kwargs["organisation_pk"] serializer.save(organisation_id=organisation_id) diff --git a/src/permissions/serializers.py b/src/permissions/serializers.py index 76e49072c2a2..1cf8a8054cc1 100644 --- a/src/permissions/serializers.py +++ b/src/permissions/serializers.py @@ -6,24 +6,28 @@ class PermissionModelSerializer(serializers.ModelSerializer): class Meta: model = PermissionModel - fields = ('key', 'description') + fields = ("key", "description") class CreateUpdateUserPermissionSerializerABC(serializers.ModelSerializer): class Meta: abstract = True - fields = ('id', 'permissions', 'admin') - read_only_fields = ('id',) + fields = ("id", "permissions", "admin") + read_only_fields = ("id",) def create(self, validated_data): - permissions = validated_data.pop('permissions', []) - instance = super(CreateUpdateUserPermissionSerializerABC, self).create(validated_data) + permissions = validated_data.pop("permissions", []) + instance = super(CreateUpdateUserPermissionSerializerABC, self).create( + validated_data + ) instance.permissions.set(permissions) return instance def update(self, instance, validated_data): - permissions = validated_data.pop('permissions', []) - instance = super(CreateUpdateUserPermissionSerializerABC, self).update(instance, validated_data) + permissions = validated_data.pop("permissions", []) + instance = super(CreateUpdateUserPermissionSerializerABC, self).update( + instance, validated_data + ) instance.permissions.set(permissions) return instance diff --git a/src/projects/admin.py b/src/projects/admin.py index 1d1f15737744..2d475c735fe2 100644 --- a/src/projects/admin.py +++ b/src/projects/admin.py @@ -2,11 +2,12 @@ from __future__ import unicode_literals from django.contrib import admin + from environments.models import Environment from features.models import Feature from projects.models import Project -from segments.models import Segment from projects.tags.models import Tag +from segments.models import Segment class EnvironmentInline(admin.StackedInline): @@ -35,14 +36,17 @@ class TagInline(admin.StackedInline): @admin.register(Project) class ProjectAdmin(admin.ModelAdmin): - date_hierarchy = 'created_date' - inlines = [ - EnvironmentInline, - FeatureInline, - SegmentInline, - TagInline - ] - list_display = ('name', 'organisation', 'created_date',) - list_filter = ('created_date', 'organisation', 'environments',) - list_select_related = ('organisation',) - search_fields = ('organisation__name',) + date_hierarchy = "created_date" + inlines = [EnvironmentInline, FeatureInline, SegmentInline, TagInline] + list_display = ( + "name", + "organisation", + "created_date", + ) + list_filter = ( + "created_date", + "organisation", + "environments", + ) + list_select_related = ("organisation",) + search_fields = ("organisation__name",) diff --git a/src/projects/apps.py b/src/projects/apps.py index c7094fece3b0..f1248d585ce7 100644 --- a/src/projects/apps.py +++ b/src/projects/apps.py @@ -5,4 +5,4 @@ class ProjectsConfig(AppConfig): - name = 'projects' + name = "projects" diff --git a/src/projects/models.py b/src/projects/models.py index ffbb4744f34c..f110ccb5a32d 100644 --- a/src/projects/models.py +++ b/src/projects/models.py @@ -7,8 +7,11 @@ from django.utils.encoding import python_2_unicode_compatible from organisations.models import Organisation -from permissions.models import BasePermissionModelABC, PermissionModel, PROJECT_PERMISSION_TYPE - +from permissions.models import ( + PROJECT_PERMISSION_TYPE, + BasePermissionModelABC, + PermissionModel, +) project_segments_cache = caches[settings.PROJECT_SEGMENTS_CACHE_LOCATION] @@ -16,13 +19,17 @@ @python_2_unicode_compatible class Project(models.Model): name = models.CharField(max_length=2000) - created_date = models.DateTimeField('DateCreated', auto_now_add=True) - organisation = models.ForeignKey(Organisation, related_name='projects', on_delete=models.CASCADE) - hide_disabled_flags = models.BooleanField(default=False, help_text='If true will exclude flags from SDK which are ' - 'disabled') + created_date = models.DateTimeField("DateCreated", auto_now_add=True) + organisation = models.ForeignKey( + Organisation, related_name="projects", on_delete=models.CASCADE + ) + hide_disabled_flags = models.BooleanField( + default=False, + help_text="If true will exclude flags from SDK which are " "disabled", + ) class Meta: - ordering = ['id'] + ordering = ["id"] def __str__(self): return "Project %s" % self.name @@ -34,16 +41,22 @@ def get_segments_from_cache(self): # this is optimised to account for rules nested two levels deep, anything past that # will require additional queries / thought on how to optimise segments = self.segments.all().prefetch_related( - 'rules', 'rules__conditions', 'rules__rules', 'rules__rules__rules' + "rules", "rules__conditions", "rules__rules", "rules__rules__rules" + ) + project_segments_cache.set( + self.id, segments, timeout=settings.CACHE_PROJECT_SEGMENTS_SECONDS ) - project_segments_cache.set(self.id, segments, timeout=settings.CACHE_PROJECT_SEGMENTS_SECONDS) return segments class ProjectPermissionManager(models.Manager): def get_queryset(self): - return super(ProjectPermissionManager, self).get_queryset().filter(type=PROJECT_PERMISSION_TYPE) + return ( + super(ProjectPermissionManager, self) + .get_queryset() + .filter(type=PROJECT_PERMISSION_TYPE) + ) class ProjectPermissionModel(PermissionModel): @@ -54,10 +67,14 @@ class Meta: class UserPermissionGroupProjectPermission(BasePermissionModelABC): - group = models.ForeignKey('users.UserPermissionGroup', on_delete=models.CASCADE) - project = models.ForeignKey(Project, on_delete=models.CASCADE, related_query_name='grouppermission') + group = models.ForeignKey("users.UserPermissionGroup", on_delete=models.CASCADE) + project = models.ForeignKey( + Project, on_delete=models.CASCADE, related_query_name="grouppermission" + ) class UserProjectPermission(BasePermissionModelABC): - user = models.ForeignKey('users.FFAdminUser', on_delete=models.CASCADE) - project = models.ForeignKey(Project, on_delete=models.CASCADE, related_query_name='userpermission') + user = models.ForeignKey("users.FFAdminUser", on_delete=models.CASCADE) + project = models.ForeignKey( + Project, on_delete=models.CASCADE, related_query_name="userpermission" + ) diff --git a/src/projects/permissions.py b/src/projects/permissions.py index 945f863bdd48..50e1f2ee3c33 100644 --- a/src/projects/permissions.py +++ b/src/projects/permissions.py @@ -15,11 +15,11 @@ class ProjectPermissions(BasePermission): def has_permission(self, request, view): """Check if user has permission to list / create project""" if view.action == "create" and request.user.belongs_to( - int(request.data.get("organisation")) + int(request.data.get("organisation")) ): return True - if view.action in ('list', 'permissions'): + if view.action in ("list", "permissions"): return True # move on to object specific permissions @@ -30,7 +30,9 @@ def has_object_permission(self, request, view, obj): if request.user.is_project_admin(obj): return True - if view.action == "retrieve" and request.user.has_project_permission("VIEW_PROJECT", obj): + if view.action == "retrieve" and request.user.has_project_permission( + "VIEW_PROJECT", obj + ): return True if view.action in ("update", "destroy") and request.user.is_project_admin(obj): @@ -44,7 +46,7 @@ def has_object_permission(self, request, view, obj): class NestedProjectPermissions(BasePermission): def has_permission(self, request, view): - project_pk = view.kwargs.get('project_pk') + project_pk = view.kwargs.get("project_pk") if not project_pk: return False diff --git a/src/projects/serializers.py b/src/projects/serializers.py index a27de1b2cdc7..44c72ebedcd4 100644 --- a/src/projects/serializers.py +++ b/src/projects/serializers.py @@ -1,31 +1,44 @@ from rest_framework import serializers from permissions.serializers import CreateUpdateUserPermissionSerializerABC -from projects.models import Project, UserProjectPermission, UserPermissionGroupProjectPermission -from users.serializers import UserListSerializer, UserPermissionGroupSerializerList +from projects.models import ( + Project, + UserPermissionGroupProjectPermission, + UserProjectPermission, +) +from users.serializers import ( + UserListSerializer, + UserPermissionGroupSerializerList, +) class ProjectSerializer(serializers.ModelSerializer): class Meta: model = Project - fields = ('id', 'name', 'organisation', 'hide_disabled_flags') + fields = ("id", "name", "organisation", "hide_disabled_flags") -class CreateUpdateUserProjectPermissionSerializer(CreateUpdateUserPermissionSerializerABC): +class CreateUpdateUserProjectPermissionSerializer( + CreateUpdateUserPermissionSerializerABC +): class Meta(CreateUpdateUserPermissionSerializerABC.Meta): model = UserProjectPermission - fields = CreateUpdateUserPermissionSerializerABC.Meta.fields + ('user',) + fields = CreateUpdateUserPermissionSerializerABC.Meta.fields + ("user",) class ListUserProjectPermissionSerializer(CreateUpdateUserProjectPermissionSerializer): user = UserListSerializer() -class CreateUpdateUserPermissionGroupProjectPermissionSerializer(CreateUpdateUserPermissionSerializerABC): +class CreateUpdateUserPermissionGroupProjectPermissionSerializer( + CreateUpdateUserPermissionSerializerABC +): class Meta(CreateUpdateUserPermissionSerializerABC.Meta): model = UserPermissionGroupProjectPermission - fields = CreateUpdateUserPermissionSerializerABC.Meta.fields + ('group',) + fields = CreateUpdateUserPermissionSerializerABC.Meta.fields + ("group",) -class ListUserPermissionGroupProjectPermissionSerializer(CreateUpdateUserPermissionGroupProjectPermissionSerializer): +class ListUserPermissionGroupProjectPermissionSerializer( + CreateUpdateUserPermissionGroupProjectPermissionSerializer +): group = UserPermissionGroupSerializerList() diff --git a/src/projects/tags/admin.py b/src/projects/tags/admin.py index 3c62ae101663..70f68f446584 100644 --- a/src/projects/tags/admin.py +++ b/src/projects/tags/admin.py @@ -6,7 +6,10 @@ # Register your models here. @admin.register(Tag) class TagAdmin(admin.ModelAdmin): - list_display = ('label', 'color', 'project') - list_filter = ('project',) - list_select_related = ('project',) - search_fields = ('label', 'project__name',) + list_display = ("label", "color", "project") + list_filter = ("project",) + list_select_related = ("project",) + search_fields = ( + "label", + "project__name", + ) diff --git a/src/projects/tags/apps.py b/src/projects/tags/apps.py index 3b46dd64823d..47215518b9fc 100644 --- a/src/projects/tags/apps.py +++ b/src/projects/tags/apps.py @@ -2,4 +2,4 @@ class TagsConfig(AppConfig): - name = 'tags' + name = "tags" diff --git a/src/projects/tags/models.py b/src/projects/tags/models.py index a73a3cd7ee1c..6dd9352eaa20 100644 --- a/src/projects/tags/models.py +++ b/src/projects/tags/models.py @@ -5,12 +5,11 @@ class Tag(models.Model): label = models.CharField(max_length=100) - color = models.CharField(max_length=10, help_text='Hexadecimal value of the tag color') + color = models.CharField( + max_length=10, help_text="Hexadecimal value of the tag color" + ) description = models.CharField(max_length=512) - project = models.ForeignKey( - Project, - on_delete=models.CASCADE, - related_name="tags") + project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="tags") def __str__(self): return "Tag %s" % self.label diff --git a/src/projects/tags/permissions.py b/src/projects/tags/permissions.py index 0f2cb332e949..284e62d6ec40 100644 --- a/src/projects/tags/permissions.py +++ b/src/projects/tags/permissions.py @@ -5,7 +5,7 @@ class TagPermissions(BasePermission): def has_permission(self, request, view): - project_pk = view.kwargs.get('project_pk') + project_pk = view.kwargs.get("project_pk") if not project_pk: return False @@ -14,7 +14,9 @@ def has_permission(self, request, view): if request.user.is_project_admin(project): return True - if view.action == 'list' and request.user.has_project_permission('VIEW_PROJECT', project): + if view.action == "list" and request.user.has_project_permission( + "VIEW_PROJECT", project + ): return True # move on to object specific permissions @@ -25,7 +27,9 @@ def has_object_permission(self, request, view, obj): if request.user.is_project_admin(obj.project): return True - if view.action == 'detail' and request.user.has_project_permission('VIEW_PROJECT', project): + if view.action == "detail" and request.user.has_project_permission( + "VIEW_PROJECT", project + ): return True - return False \ No newline at end of file + return False diff --git a/src/projects/tags/serializers.py b/src/projects/tags/serializers.py index 4fecc87adaba..4cef5e897907 100644 --- a/src/projects/tags/serializers.py +++ b/src/projects/tags/serializers.py @@ -1,9 +1,10 @@ -from projects.tags.models import Tag from rest_framework import serializers +from projects.tags.models import Tag + class TagSerializer(serializers.ModelSerializer): class Meta: model = Tag - fields = ('id', 'label', 'color', 'description', 'project') - read_only_fields = ('project',) + fields = ("id", "label", "color", "description", "project") + read_only_fields = ("project",) diff --git a/src/projects/tags/tests/test_models.py b/src/projects/tags/tests/test_models.py index ebc76e7a29b1..a186d6eb623a 100644 --- a/src/projects/tags/tests/test_models.py +++ b/src/projects/tags/tests/test_models.py @@ -1,5 +1,6 @@ import pytest from django.test import TestCase + from organisations.models import Organisation from projects.models import Project from projects.tags.models import Tag @@ -8,15 +9,19 @@ @pytest.mark.django_db class TagsTestCase(TestCase): def setUp(self) -> None: - self.organisation = Organisation.objects.create(name='Test Org') - self.project = Project.objects.create(name='Test Project', organisation=self.organisation) + self.organisation = Organisation.objects.create(name="Test Org") + self.project = Project.objects.create( + name="Test Project", organisation=self.organisation + ) def test_create_tag(self): # When - tag = Tag.objects.create(label='Test Tag', - color='#fffff', - description='Test Tag description', - project=self.project) + tag = Tag.objects.create( + label="Test Tag", + color="#fffff", + description="Test Tag description", + project=self.project, + ) # Then assert tag.project.id == self.project.id diff --git a/src/projects/tags/tests/test_permissions.py b/src/projects/tags/tests/test_permissions.py index b0eb963fbfb6..b8edcf31f226 100644 --- a/src/projects/tags/tests/test_permissions.py +++ b/src/projects/tags/tests/test_permissions.py @@ -18,18 +18,26 @@ class TagPermissionsTestCase(TestCase): def setUp(self): organisation = Organisation.objects.create(name="Test org") - self.project = Project.objects.create(name="Test project", organisation=organisation) + self.project = Project.objects.create( + name="Test project", organisation=organisation + ) self.tag = Tag.objects.create(label="test", project=self.project) - mock_view.kwargs = {'project_pk': self.project.id} + mock_view.kwargs = {"project_pk": self.project.id} - self.project_admin = FFAdminUser.objects.create(email="project_admin@example.com") + self.project_admin = FFAdminUser.objects.create( + email="project_admin@example.com" + ) self.project_admin.add_organisation(organisation) - UserProjectPermission.objects.create(user=self.project_admin, admin=True, project=self.project) + UserProjectPermission.objects.create( + user=self.project_admin, admin=True, project=self.project + ) self.project_user = FFAdminUser.objects.create(email="project_user@example.com") self.project_user.add_organisation(organisation) - user_project_permission = UserProjectPermission.objects.create(user=self.project_user, project=self.project) - user_project_permission.add_permission('VIEW_PROJECT') + user_project_permission = UserProjectPermission.objects.create( + user=self.project_user, project=self.project + ) + user_project_permission.add_permission("VIEW_PROJECT") def test_project_admin_has_permission(self): # Given @@ -37,7 +45,7 @@ def test_project_admin_has_permission(self): # When results = [] - for action in ['list', 'create']: + for action in ["list", "create"]: mock_view.action = action results.append(permissions.has_permission(mock_request, mock_view)) @@ -50,9 +58,11 @@ def test_project_admin_has_object_permission(self): # When results = [] - for action in ['update', 'delete', 'detail']: + for action in ["update", "delete", "detail"]: mock_view.action = action - results.append(permissions.has_object_permission(mock_request, mock_view, self.tag)) + results.append( + permissions.has_object_permission(mock_request, mock_view, self.tag) + ) # Then assert all(results) @@ -63,7 +73,7 @@ def test_project_user_has_list_permission(self): mock_view.detail = False # When - mock_view.action = 'list' + mock_view.action = "list" result = permissions.has_permission(mock_request, mock_view) # Then @@ -75,7 +85,7 @@ def test_project_user_has_no_create_permission(self): mock_view.detail = False # When - mock_view.action = 'create' + mock_view.action = "create" result = permissions.has_permission(mock_request, mock_view) # Then @@ -87,9 +97,11 @@ def test_project_user_has_no_update_or_delete_permission(self): # When results = [] - for action in ['update', 'delete']: + for action in ["update", "delete"]: mock_view.action = action - results.append(permissions.has_object_permission(mock_request, mock_view, self.tag)) + results.append( + permissions.has_object_permission(mock_request, mock_view, self.tag) + ) # Then assert all(result is False for result in results) @@ -99,7 +111,7 @@ def test_project_user_has_detail_permission(self): mock_request.user = self.project_user # When - mock_view.action = 'detail' + mock_view.action = "detail" result = permissions.has_object_permission(mock_request, mock_view, self.tag) # Then diff --git a/src/projects/tags/views.py b/src/projects/tags/views.py index 8a305a62040b..a4de468479d4 100644 --- a/src/projects/tags/views.py +++ b/src/projects/tags/views.py @@ -11,8 +11,10 @@ class TagViewSet(viewsets.ModelViewSet): permission_classes = [IsAuthenticated, TagPermissions] def get_queryset(self): - project = get_object_or_404(self.request.user.get_permitted_projects(['VIEW_PROJECT']), - pk=self.kwargs['project_pk']) + project = get_object_or_404( + self.request.user.get_permitted_projects(["VIEW_PROJECT"]), + pk=self.kwargs["project_pk"], + ) queryset = project.tags.all() return queryset diff --git a/src/projects/tests/conftest.py b/src/projects/tests/conftest.py index 8ea5d0ae097f..1aaea8f97144 100644 --- a/src/projects/tests/conftest.py +++ b/src/projects/tests/conftest.py @@ -7,17 +7,19 @@ @pytest.fixture() def organisation(): - return Organisation.objects.create(name='Test Organisation') + return Organisation.objects.create(name="Test Organisation") @pytest.fixture() def project(organisation): - return Project.objects.create(name='Test Project', organisation=organisation) + return Project.objects.create(name="Test Project", organisation=organisation) @pytest.fixture() def segments(project): segments = [] for i in range(3): - segments.append(Segment.objects.create(name=f'Test Segment {i}', project=project)) + segments.append( + Segment.objects.create(name=f"Test Segment {i}", project=project) + ) return segments diff --git a/src/projects/tests/test_models.py b/src/projects/tests/test_models.py index 23e297fc4ce5..992afca71b53 100644 --- a/src/projects/tests/test_models.py +++ b/src/projects/tests/test_models.py @@ -7,7 +7,7 @@ from organisations.models import Organisation from projects.models import Project -from segments.models import Segment, SegmentRule, Condition, EQUAL +from segments.models import EQUAL, Condition, Segment, SegmentRule @pytest.mark.django_db() @@ -16,7 +16,9 @@ def test_get_segments_from_cache(project, monkeypatch): mock_project_segments_cache = mock.MagicMock() mock_project_segments_cache.get.return_value = None - monkeypatch.setattr('projects.models.project_segments_cache', mock_project_segments_cache) + monkeypatch.setattr( + "projects.models.project_segments_cache", mock_project_segments_cache + ) # When segments = project.get_segments_from_cache() @@ -34,7 +36,9 @@ def test_get_segments_from_cache_set_not_called(project, segments, monkeypatch): mock_project_segments_cache = mock.MagicMock() mock_project_segments_cache.get.return_value = project.segments.all() - monkeypatch.setattr('projects.models.project_segments_cache', mock_project_segments_cache) + monkeypatch.setattr( + "projects.models.project_segments_cache", mock_project_segments_cache + ) # When segments = project.get_segments_from_cache() diff --git a/src/projects/tests/test_permissions.py b/src/projects/tests/test_permissions.py index 99153d6ec079..cf883a217b7a 100644 --- a/src/projects/tests/test_permissions.py +++ b/src/projects/tests/test_permissions.py @@ -3,8 +3,13 @@ import pytest from organisations.models import Organisation, OrganisationRole -from projects.models import Project, UserPermissionGroupProjectPermission, UserProjectPermission, ProjectPermissionModel -from projects.permissions import ProjectPermissions, NestedProjectPermissions +from projects.models import ( + Project, + ProjectPermissionModel, + UserPermissionGroupProjectPermission, + UserProjectPermission, +) +from projects.permissions import NestedProjectPermissions, ProjectPermissions from users.models import FFAdminUser, UserPermissionGroup mock_request = mock.MagicMock @@ -14,11 +19,15 @@ @pytest.mark.django_db class UserPermissionGroupProjectPermissionsTestCase(TestCase): def setUp(self) -> None: - self.organisation = Organisation.objects.create(name='Test organisation') - self.project = Project.objects.create(name='Test project', organisation=self.organisation) - - self.user = FFAdminUser.objects.create(email='user@test.com') - self.user_permission_group = UserPermissionGroup.objects.create(name='Users', organisation=self.organisation) + self.organisation = Organisation.objects.create(name="Test organisation") + self.project = Project.objects.create( + name="Test project", organisation=self.organisation + ) + + self.user = FFAdminUser.objects.create(email="user@test.com") + self.user_permission_group = UserPermissionGroup.objects.create( + name="Users", organisation=self.organisation + ) self.user_permission_group.users.add(self.user) self.user.add_organisation(self.organisation, OrganisationRole.USER) @@ -29,7 +38,7 @@ def setUp(self) -> None: def test_list_project(self): """All users should be able to create project""" # Given - mock_view.action = 'list' + mock_view.action = "list" mock_view.detail = False # When @@ -41,12 +50,9 @@ def test_list_project(self): def test_create_project(self): """All users should be able to create project""" # Given - mock_view.action = 'create' + mock_view.action = "create" mock_view.detail = False - mock_request.data = { - 'name': 'Test', - 'organisation': self.organisation.id - } + mock_request.data = {"name": "Test", "organisation": self.organisation.id} # When mock_request.user = self.user @@ -56,49 +62,63 @@ def test_create_project(self): def test_admin_group_can_update_project(self): # Given - UserPermissionGroupProjectPermission.objects.create(group=self.user_permission_group, admin=True, - project=self.project) + UserPermissionGroupProjectPermission.objects.create( + group=self.user_permission_group, admin=True, project=self.project + ) mock_view.action = "update" mock_view.detail = True mock_request.user = self.user # When - result = self.project_permissions.has_object_permission(mock_request, mock_view, self.project) + result = self.project_permissions.has_object_permission( + mock_request, mock_view, self.project + ) # Then assert result def test_admin_user_can_update_project(self): # Given - UserProjectPermission.objects.create(user=self.user, admin=True, project=self.project) + UserProjectPermission.objects.create( + user=self.user, admin=True, project=self.project + ) mock_view.action = "update" mock_view.detail = True mock_request.user = self.user # When - result = self.project_permissions.has_object_permission(mock_request, mock_view, self.project) + result = self.project_permissions.has_object_permission( + mock_request, mock_view, self.project + ) # Then assert result def test_admin_user_and_admin_group_can_update_project(self): # Given - UserProjectPermission.objects.create(user=self.user, admin=True, project=self.project) - UserPermissionGroupProjectPermission.objects.create(group=self.user_permission_group, admin=True, - project=self.project) + UserProjectPermission.objects.create( + user=self.user, admin=True, project=self.project + ) + UserPermissionGroupProjectPermission.objects.create( + group=self.user_permission_group, admin=True, project=self.project + ) mock_view.action = "update" mock_view.detail = True mock_request.user = self.user # When - result = self.project_permissions.has_object_permission(mock_request, mock_view, self.project) + result = self.project_permissions.has_object_permission( + mock_request, mock_view, self.project + ) # Then assert result def test_regular_user_cannot_update_project(self): # Given - user_project_permission = UserProjectPermission.objects.create(user=self.user, project=self.project) + user_project_permission = UserProjectPermission.objects.create( + user=self.user, project=self.project + ) user_project_permission.permissions.add(self.read_permission) mock_view.action = "update" @@ -106,28 +126,35 @@ def test_regular_user_cannot_update_project(self): mock_request.user = self.user # When - result = self.project_permissions.has_object_permission(mock_request, mock_view, self.project) + result = self.project_permissions.has_object_permission( + mock_request, mock_view, self.project + ) # Then assert not result def test_admin_can_delete_project(self): # Given - UserPermissionGroupProjectPermission.objects.create(group=self.user_permission_group, admin=True, - project=self.project) + UserPermissionGroupProjectPermission.objects.create( + group=self.user_permission_group, admin=True, project=self.project + ) mock_view.action = "destroy" mock_view.detail = True mock_request.user = self.user # When - result = self.project_permissions.has_object_permission(mock_request, mock_view, self.project) + result = self.project_permissions.has_object_permission( + mock_request, mock_view, self.project + ) # Then assert result def test_regular_user_cannot_delete_project(self): # Given - user_project_permission = UserProjectPermission.objects.create(user=self.user, project=self.project) + user_project_permission = UserProjectPermission.objects.create( + user=self.user, project=self.project + ) user_project_permission.permissions.add(self.read_permission) mock_view.action = "destroy" @@ -135,7 +162,9 @@ def test_regular_user_cannot_delete_project(self): mock_request.user = self.user # When - result = self.project_permissions.has_object_permission(mock_request, mock_view, self.project) + result = self.project_permissions.has_object_permission( + mock_request, mock_view, self.project + ) # Then assert not result @@ -147,35 +176,42 @@ def test_organisation_admin_can_perform_all_actions_on_a_project(self): list_actions = ("list", "create") detail_actions = ("update", "destroy", "delete", "retrieve") mock_request.user = organisation_admin - mock_request.data = { - 'name': 'Test', - 'organisation': self.organisation.id - } + mock_request.data = {"name": "Test", "organisation": self.organisation.id} # When results = [] for action in list_actions: mock_view.action = action mock_view.detail = False - results.append(self.project_permissions.has_permission(mock_request, mock_view)) + results.append( + self.project_permissions.has_permission(mock_request, mock_view) + ) for action in detail_actions: mock_view.action = action mock_view.detail = True - results.append(self.project_permissions.has_object_permission(mock_request, mock_view, self.project)) + results.append( + self.project_permissions.has_object_permission( + mock_request, mock_view, self.project + ) + ) # Then assert all(result for result in results) def test_user_with_view_project_permission_can_view_project(self): # Given - user_project_permission = UserProjectPermission.objects.create(user=self.user, project=self.project) - user_project_permission.add_permission('VIEW_PROJECT') - mock_view.action = 'retrieve' + user_project_permission = UserProjectPermission.objects.create( + user=self.user, project=self.project + ) + user_project_permission.add_permission("VIEW_PROJECT") + mock_view.action = "retrieve" mock_request.user = self.user # When - result = self.project_permissions.has_object_permission(mock_request, mock_view, self.project) + result = self.project_permissions.has_object_permission( + mock_request, mock_view, self.project + ) # Then assert result @@ -184,29 +220,35 @@ def test_user_with_view_project_permission_can_view_project(self): @pytest.mark.django_db class ProjectPermissionPermissionsTestCase(TestCase): def setUp(self) -> None: - organisation = Organisation.objects.create(name='Test') + organisation = Organisation.objects.create(name="Test") - self.org_admin = FFAdminUser.objects.create(email='admin@test.com') - self.user = FFAdminUser.objects.create(email='user@test.com') + self.org_admin = FFAdminUser.objects.create(email="admin@test.com") + self.user = FFAdminUser.objects.create(email="user@test.com") self.org_admin.add_organisation(organisation, OrganisationRole.ADMIN) self.user.add_organisation(organisation, OrganisationRole.USER) - self.project = Project.objects.create(name='Test project', organisation=organisation) + self.project = Project.objects.create( + name="Test project", organisation=organisation + ) self.permissions = NestedProjectPermissions() - mock_view.kwargs = {'project_pk': self.project.id} + mock_view.kwargs = {"project_pk": self.project.id} self.read_permission = ProjectPermissionModel.objects.get(key="VIEW_PROJECT") - self.user_project_permission = UserProjectPermission.objects.create(user=self.user, project=self.project) + self.user_project_permission = UserProjectPermission.objects.create( + user=self.user, project=self.project + ) self.user_project_permission.permissions.set([self.read_permission]) def test_project_admin_has_permission(self): # Given - UserProjectPermission.objects.create(user=self.user, admin=True, project=self.project) + UserProjectPermission.objects.create( + user=self.user, admin=True, project=self.project + ) mock_request.user = self.user - actions = ['list', 'create'] + actions = ["list", "create"] mock_view.detail = False # When @@ -220,9 +262,11 @@ def test_project_admin_has_permission(self): def test_project_admin_has_object_permission(self): # Given - UserProjectPermission.objects.create(user=self.user, admin=True, project=self.project) + UserProjectPermission.objects.create( + user=self.user, admin=True, project=self.project + ) mock_request.user = self.user - actions = ['update', 'destroy'] + actions = ["update", "destroy"] mock_view.detail = True # When @@ -230,7 +274,10 @@ def test_project_admin_has_object_permission(self): for action in actions: mock_view.action = action results.append( - self.permissions.has_object_permission(mock_request, mock_view, self.user_project_permission)) + self.permissions.has_object_permission( + mock_request, mock_view, self.user_project_permission + ) + ) # Then assert all(results) @@ -238,7 +285,7 @@ def test_project_admin_has_object_permission(self): def test_organisation_admin_has_permission(self): # Given mock_request.user = self.org_admin - actions = ['list', 'create'] + actions = ["list", "create"] mock_view.detail = False # When @@ -253,7 +300,7 @@ def test_organisation_admin_has_permission(self): def test_organisation_admin_has_object_permission(self): # Given mock_request.user = self.org_admin - actions = ['update', 'destroy'] + actions = ["update", "destroy"] mock_view.detail = True # When @@ -261,7 +308,10 @@ def test_organisation_admin_has_object_permission(self): for action in actions: mock_view.action = action results.append( - self.permissions.has_object_permission(mock_request, mock_view, self.user_project_permission)) + self.permissions.has_object_permission( + mock_request, mock_view, self.user_project_permission + ) + ) # Then assert all(results) @@ -269,7 +319,7 @@ def test_organisation_admin_has_object_permission(self): def test_regular_user_has_no_list_permission(self): # Given mock_request.user = self.user - mock_view.action = 'list' + mock_view.action = "list" mock_view.detail = False # When @@ -281,7 +331,7 @@ def test_regular_user_has_no_list_permission(self): def test_regular_user_has_no_create_permission(self): # Given mock_request.user = self.user - mock_view.action = 'create' + mock_view.action = "create" mock_view.detail = False # When @@ -293,11 +343,13 @@ def test_regular_user_has_no_create_permission(self): def test_regular_user_has_no_update_permission(self): # Given mock_request.user = self.user - mock_view.action = 'update' + mock_view.action = "update" mock_view.detail = True # When - result = self.permissions.has_object_permission(mock_request, mock_view, self.user_project_permission) + result = self.permissions.has_object_permission( + mock_request, mock_view, self.user_project_permission + ) # Then - exception thrown assert not result @@ -305,11 +357,13 @@ def test_regular_user_has_no_update_permission(self): def test_regular_user_has_no_destroy_permission(self): # Given mock_request.user = self.user - mock_view.action = 'destroy' + mock_view.action = "destroy" mock_view.detail = True # When - result = self.permissions.has_object_permission(mock_request, mock_view, self.user_project_permission) + result = self.permissions.has_object_permission( + mock_request, mock_view, self.user_project_permission + ) # Then - exception thrown assert not result diff --git a/src/projects/tests/test_views.py b/src/projects/tests/test_views.py index 4c71adf34fa2..994471fc4ff2 100644 --- a/src/projects/tests/test_views.py +++ b/src/projects/tests/test_views.py @@ -7,7 +7,12 @@ from rest_framework.test import APIClient from organisations.models import Organisation, OrganisationRole -from projects.models import Project, UserProjectPermission, ProjectPermissionModel, UserPermissionGroupProjectPermission +from projects.models import ( + Project, + ProjectPermissionModel, + UserPermissionGroupProjectPermission, + UserProjectPermission, +) from users.models import FFAdminUser, UserPermissionGroup @@ -15,24 +20,21 @@ class ProjectTestCase(TestCase): def setUp(self): self.client = APIClient() - self.user = FFAdminUser.objects.create(email='admin@test.com') + self.user = FFAdminUser.objects.create(email="admin@test.com") self.client.force_authenticate(user=self.user) self.organisation = Organisation.objects.create(name="Test org") self.user.add_organisation(self.organisation, OrganisationRole.ADMIN) - self.list_url = reverse('api-v1:projects:project-list') + self.list_url = reverse("api-v1:projects:project-list") def _get_detail_url(self, project_id): - return reverse('api-v1:projects:project-detail', args=[project_id]) + return reverse("api-v1:projects:project-detail", args=[project_id]) def test_should_create_a_project(self): # Given - project_name = 'project1' - data = { - 'name': project_name, - 'organisation': self.organisation.id - } + project_name = "project1" + data = {"name": project_name, "organisation": self.organisation.id} # When response = self.client.post(self.list_url, data=data) @@ -42,31 +44,39 @@ def test_should_create_a_project(self): assert Project.objects.filter(name=project_name).count() == 1 # and user is admin - assert UserProjectPermission.objects.filter(user=self.user, project__id=response.json()['id'], admin=True) + assert UserProjectPermission.objects.filter( + user=self.user, project__id=response.json()["id"], admin=True + ) # and they can get the project - url = reverse('api-v1:projects:project-detail', args=[response.json()['id']]) + url = reverse("api-v1:projects:project-detail", args=[response.json()["id"]]) get_project_response = self.client.get(url) assert get_project_response.status_code == status.HTTP_200_OK def test_user_can_list_project_permission(self): # Given - url = reverse('api-v1:projects:project-permissions') + url = reverse("api-v1:projects:project-permissions") # When response = self.client.get(url) # Then assert response.status_code == status.HTTP_200_OK - assert len(response.json()) == 4 # hard code how many permissions we expect there to be + assert ( + len(response.json()) == 4 + ) # hard code how many permissions we expect there to be def test_user_with_view_project_permission_can_view_project(self): # Given - user = FFAdminUser.objects.create(email='test@test.com') - project = Project.objects.create(name='Test project', organisation=self.organisation) - user_project_permission = UserProjectPermission.objects.create(user=user, project=project) - user_project_permission.add_permission('VIEW_PROJECT') - url = reverse('api-v1:projects:project-detail', args=[project.id]) + user = FFAdminUser.objects.create(email="test@test.com") + project = Project.objects.create( + name="Test project", organisation=self.organisation + ) + user_project_permission = UserProjectPermission.objects.create( + user=user, project=project + ) + user_project_permission.add_permission("VIEW_PROJECT") + url = reverse("api-v1:projects:project-detail", args=[project.id]) # When response = self.client.get(url) @@ -74,14 +84,20 @@ def test_user_with_view_project_permission_can_view_project(self): # Then assert response.status_code == status.HTTP_200_OK - def test_user_with_view_project_permission_can_get_their_permissions_for_a_project(self): + def test_user_with_view_project_permission_can_get_their_permissions_for_a_project( + self, + ): # Given - user = FFAdminUser.objects.create(email='test@test.com') - project = Project.objects.create(name='Test project', organisation=self.organisation) + user = FFAdminUser.objects.create(email="test@test.com") + project = Project.objects.create( + name="Test project", organisation=self.organisation + ) user.add_organisation(self.organisation) - user_project_permission = UserProjectPermission.objects.create(user=user, project=project) - user_project_permission.add_permission('VIEW_PROJECT') - url = reverse('api-v1:projects:project-my-permissions', args=[project.id]) + user_project_permission = UserProjectPermission.objects.create( + user=user, project=project + ) + user_project_permission.add_permission("VIEW_PROJECT") + url = reverse("api-v1:projects:project-my-permissions", args=[project.id]) # When self.client.force_authenticate(user) @@ -94,26 +110,34 @@ def test_user_with_view_project_permission_can_get_their_permissions_for_a_proje @pytest.mark.django_db class UserProjectPermissionsViewSetTestCase(TestCase): def setUp(self) -> None: - self.organisation = Organisation.objects.create(name='Test') - self.project = Project.objects.create(name='Test', organisation=self.organisation) + self.organisation = Organisation.objects.create(name="Test") + self.project = Project.objects.create( + name="Test", organisation=self.organisation + ) # Admin to bypass permission checks - self.org_admin = FFAdminUser.objects.create(email='admin@test.com') + self.org_admin = FFAdminUser.objects.create(email="admin@test.com") self.org_admin.add_organisation(self.organisation, OrganisationRole.ADMIN) # create a project user - user = FFAdminUser.objects.create(email='user@test.com') + user = FFAdminUser.objects.create(email="user@test.com") user.add_organisation(self.organisation, OrganisationRole.USER) read_permission = ProjectPermissionModel.objects.get(key="VIEW_PROJECT") - self.user_project_permission = UserProjectPermission.objects.create(user=user, project=self.project) + self.user_project_permission = UserProjectPermission.objects.create( + user=user, project=self.project + ) self.user_project_permission.permissions.set([read_permission]) self.client = APIClient() self.client.force_authenticate(self.org_admin) - self.list_url = reverse('api-v1:projects:project-user-permissions-list', args=[self.project.id]) - self.detail_url = reverse('api-v1:projects:project-user-permissions-detail', - args=[self.project.id, self.user_project_permission.id]) + self.list_url = reverse( + "api-v1:projects:project-user-permissions-list", args=[self.project.id] + ) + self.detail_url = reverse( + "api-v1:projects:project-user-permissions-detail", + args=[self.project.id, self.user_project_permission.id], + ) def test_user_can_list_all_user_permissions_for_a_project(self): # Given - set up data @@ -127,44 +151,47 @@ def test_user_can_list_all_user_permissions_for_a_project(self): def test_user_can_create_new_user_permission_for_a_project(self): # Given - new_user = FFAdminUser.objects.create(email='new_user@test.com') + new_user = FFAdminUser.objects.create(email="new_user@test.com") new_user.add_organisation(self.organisation, OrganisationRole.USER) data = { - 'user': new_user.id, - 'permissions': [ - "VIEW_PROJECT", - "CREATE_ENVIRONMENT" - ], - 'admin': False + "user": new_user.id, + "permissions": ["VIEW_PROJECT", "CREATE_ENVIRONMENT"], + "admin": False, } # When - response = self.client.post(self.list_url, data=json.dumps(data), content_type='application/json') + response = self.client.post( + self.list_url, data=json.dumps(data), content_type="application/json" + ) # Then assert response.status_code == status.HTTP_201_CREATED - assert sorted(response.json()['permissions']) == sorted(data['permissions']) + assert sorted(response.json()["permissions"]) == sorted(data["permissions"]) - assert UserProjectPermission.objects.filter(user=new_user, project=self.project).exists() - user_project_permission = UserProjectPermission.objects.get(user=new_user, project=self.project) + assert UserProjectPermission.objects.filter( + user=new_user, project=self.project + ).exists() + user_project_permission = UserProjectPermission.objects.get( + user=new_user, project=self.project + ) assert user_project_permission.permissions.count() == 2 def test_user_can_update_user_permission_for_a_project(self): # Given - data = { - 'permissions': [ - 'CREATE_FEATURE' - ] - } + data = {"permissions": ["CREATE_FEATURE"]} # When - response = self.client.patch(self.detail_url, data=json.dumps(data), content_type='application/json') + response = self.client.patch( + self.detail_url, data=json.dumps(data), content_type="application/json" + ) # Then assert response.status_code == status.HTTP_200_OK self.user_project_permission.refresh_from_db() - assert 'CREATE_FEATURE' in self.user_project_permission.permissions.values_list('key', flat=True) + assert "CREATE_FEATURE" in self.user_project_permission.permissions.values_list( + "key", flat=True + ) def test_user_can_delete_user_permission_for_a_project(self): # Given - set up data @@ -174,40 +201,51 @@ def test_user_can_delete_user_permission_for_a_project(self): # Then assert response.status_code == status.HTTP_204_NO_CONTENT - assert not UserProjectPermission.objects.filter(id=self.user_project_permission.id).exists() + assert not UserProjectPermission.objects.filter( + id=self.user_project_permission.id + ).exists() @pytest.mark.django_db class UserPermissionGroupProjectPermissionsViewSetTestCase(TestCase): def setUp(self) -> None: - self.organisation = Organisation.objects.create(name='Test') - self.project = Project.objects.create(name='Test', organisation=self.organisation) + self.organisation = Organisation.objects.create(name="Test") + self.project = Project.objects.create( + name="Test", organisation=self.organisation + ) # Admin to bypass permission checks - self.org_admin = FFAdminUser.objects.create(email='admin@test.com') + self.org_admin = FFAdminUser.objects.create(email="admin@test.com") self.org_admin.add_organisation(self.organisation, OrganisationRole.ADMIN) # create a project user - self.user = FFAdminUser.objects.create(email='user@test.com') + self.user = FFAdminUser.objects.create(email="user@test.com") self.user.add_organisation(self.organisation, OrganisationRole.USER) read_permission = ProjectPermissionModel.objects.get(key="VIEW_PROJECT") - self.user_permission_group = UserPermissionGroup.objects.create(name='Test group', - organisation=self.organisation) + self.user_permission_group = UserPermissionGroup.objects.create( + name="Test group", organisation=self.organisation + ) self.user_permission_group.users.add(self.user) - self.user_group_project_permission = UserPermissionGroupProjectPermission.objects.create( - group=self.user_permission_group, - project=self.project + self.user_group_project_permission = ( + UserPermissionGroupProjectPermission.objects.create( + group=self.user_permission_group, project=self.project + ) ) self.user_group_project_permission.permissions.set([read_permission]) self.client = APIClient() self.client.force_authenticate(self.org_admin) - self.list_url = reverse('api-v1:projects:project-user-group-permissions-list', args=[self.project.id]) - self.detail_url = reverse('api-v1:projects:project-user-group-permissions-detail', - args=[self.project.id, self.user_group_project_permission.id]) + self.list_url = reverse( + "api-v1:projects:project-user-group-permissions-list", + args=[self.project.id], + ) + self.detail_url = reverse( + "api-v1:projects:project-user-group-permissions-detail", + args=[self.project.id, self.user_group_project_permission.id], + ) def test_user_can_list_all_user_group_permissions_for_a_project(self): # Given - set up data @@ -221,45 +259,54 @@ def test_user_can_list_all_user_group_permissions_for_a_project(self): def test_user_can_create_new_user_group_permission_for_a_project(self): # Given - new_group = UserPermissionGroup.objects.create(name='New group', organisation=self.organisation) + new_group = UserPermissionGroup.objects.create( + name="New group", organisation=self.organisation + ) new_group.users.add(self.user) data = { - 'group': new_group.id, - 'permissions': [ - "VIEW_PROJECT", - "CREATE_ENVIRONMENT" - ], - 'admin': False + "group": new_group.id, + "permissions": ["VIEW_PROJECT", "CREATE_ENVIRONMENT"], + "admin": False, } # When - response = self.client.post(self.list_url, data=json.dumps(data), content_type='application/json') + response = self.client.post( + self.list_url, data=json.dumps(data), content_type="application/json" + ) # Then assert response.status_code == status.HTTP_201_CREATED - assert sorted(response.json()['permissions']) == sorted(data['permissions']) - - assert UserPermissionGroupProjectPermission.objects.filter(group=new_group, project=self.project).exists() - user_group_project_permission = UserPermissionGroupProjectPermission.objects.get(group=new_group, - project=self.project) + assert sorted(response.json()["permissions"]) == sorted(data["permissions"]) + + assert UserPermissionGroupProjectPermission.objects.filter( + group=new_group, project=self.project + ).exists() + user_group_project_permission = ( + UserPermissionGroupProjectPermission.objects.get( + group=new_group, project=self.project + ) + ) assert user_group_project_permission.permissions.count() == 2 def test_user_can_update_user_group_permission_for_a_project(self): # Given - data = { - 'permissions': [ - 'CREATE_FEATURE' - ] - } + data = {"permissions": ["CREATE_FEATURE"]} # When - response = self.client.patch(self.detail_url, data=json.dumps(data), content_type='application/json') + response = self.client.patch( + self.detail_url, data=json.dumps(data), content_type="application/json" + ) # Then assert response.status_code == status.HTTP_200_OK self.user_group_project_permission.refresh_from_db() - assert 'CREATE_FEATURE' in self.user_group_project_permission.permissions.values_list('key', flat=True) + assert ( + "CREATE_FEATURE" + in self.user_group_project_permission.permissions.values_list( + "key", flat=True + ) + ) def test_user_can_delete_user_permission_for_a_project(self): # Given - set up data @@ -270,4 +317,5 @@ def test_user_can_delete_user_permission_for_a_project(self): # Then assert response.status_code == status.HTTP_204_NO_CONTENT assert not UserPermissionGroupProjectPermission.objects.filter( - id=self.user_group_project_permission.id).exists() + id=self.user_group_project_permission.id + ).exists() diff --git a/src/projects/urls.py b/src/projects/urls.py index daa54265d8b9..c351534c78df 100644 --- a/src/projects/urls.py +++ b/src/projects/urls.py @@ -1,28 +1,43 @@ -from django.conf.urls import url, include +from django.conf.urls import include, url from rest_framework_nested import routers from features.views import FeatureViewSet from integrations.datadog.views import DataDogConfigurationViewSet from projects.tags.views import TagViewSet from segments.views import SegmentViewSet + from . import views -from .views import UserProjectPermissionsViewSet, UserPermissionGroupProjectPermissionsViewSet +from .views import ( + UserPermissionGroupProjectPermissionsViewSet, + UserProjectPermissionsViewSet, +) router = routers.DefaultRouter() -router.register(r'', views.ProjectViewSet, basename="project") +router.register(r"", views.ProjectViewSet, basename="project") -projects_router = routers.NestedSimpleRouter(router, r'', lookup="project") -projects_router.register(r'features', FeatureViewSet, basename="project-features") -projects_router.register(r'segments', SegmentViewSet, basename="project-segments") -projects_router.register(r'user-permissions', UserProjectPermissionsViewSet, basename='project-user-permissions') -projects_router.register(r'user-group-permissions', UserPermissionGroupProjectPermissionsViewSet, - basename='project-user-group-permissions') -projects_router.register(r'tags', TagViewSet, basename="tags") -projects_router.register(r'integrations/datadog', DataDogConfigurationViewSet, basename="integrations-datadog") +projects_router = routers.NestedSimpleRouter(router, r"", lookup="project") +projects_router.register(r"features", FeatureViewSet, basename="project-features") +projects_router.register(r"segments", SegmentViewSet, basename="project-segments") +projects_router.register( + r"user-permissions", + UserProjectPermissionsViewSet, + basename="project-user-permissions", +) +projects_router.register( + r"user-group-permissions", + UserPermissionGroupProjectPermissionsViewSet, + basename="project-user-group-permissions", +) +projects_router.register(r"tags", TagViewSet, basename="tags") +projects_router.register( + r"integrations/datadog", + DataDogConfigurationViewSet, + basename="integrations-datadog", +) app_name = "projects" urlpatterns = [ - url(r'^', include(router.urls)), - url(r'^', include(projects_router.urls)) + url(r"^", include(router.urls)), + url(r"^", include(projects_router.urls)), ] diff --git a/src/projects/views.py b/src/projects/views.py index 284fe487720a..0feb510dfb48 100644 --- a/src/projects/views.py +++ b/src/projects/views.py @@ -11,17 +11,40 @@ from rest_framework.response import Response from environments.serializers import EnvironmentSerializerLight -from permissions.serializers import PermissionModelSerializer, MyUserObjectPermissionsSerializer -from projects.models import UserPermissionGroupProjectPermission, UserProjectPermission, Project, ProjectPermissionModel -from projects.permissions import ProjectPermissions, NestedProjectPermissions -from projects.serializers import ProjectSerializer, CreateUpdateUserProjectPermissionSerializer, ListUserProjectPermissionSerializer, \ - ListUserPermissionGroupProjectPermissionSerializer, CreateUpdateUserPermissionGroupProjectPermissionSerializer - - -@method_decorator(name='list', decorator=swagger_auto_schema(manual_parameters=[ - openapi.Parameter('organisation', openapi.IN_QUERY, - 'ID of the organisation to filter by.', required=False, type=openapi.TYPE_INTEGER) -])) +from permissions.serializers import ( + MyUserObjectPermissionsSerializer, + PermissionModelSerializer, +) +from projects.models import ( + Project, + ProjectPermissionModel, + UserPermissionGroupProjectPermission, + UserProjectPermission, +) +from projects.permissions import NestedProjectPermissions, ProjectPermissions +from projects.serializers import ( + CreateUpdateUserPermissionGroupProjectPermissionSerializer, + CreateUpdateUserProjectPermissionSerializer, + ListUserPermissionGroupProjectPermissionSerializer, + ListUserProjectPermissionSerializer, + ProjectSerializer, +) + + +@method_decorator( + name="list", + decorator=swagger_auto_schema( + manual_parameters=[ + openapi.Parameter( + "organisation", + openapi.IN_QUERY, + "ID of the organisation to filter by.", + required=False, + type=openapi.TYPE_INTEGER, + ) + ] + ), +) class ProjectViewSet(viewsets.ModelViewSet): serializer_class = ProjectSerializer permission_classes = [IsAuthenticated, ProjectPermissions] @@ -29,7 +52,7 @@ class ProjectViewSet(viewsets.ModelViewSet): def get_queryset(self): user = self.request.user - queryset = user.get_permitted_projects(permissions=['VIEW_PROJECT']) + queryset = user.get_permitted_projects(permissions=["VIEW_PROJECT"]) organisation_id = self.request.query_params.get("organisation") if organisation_id: @@ -39,7 +62,9 @@ def get_queryset(self): def perform_create(self, serializer): project = serializer.save() - UserProjectPermission.objects.create(user=self.request.user, project=project, admin=True) + UserProjectPermission.objects.create( + user=self.request.user, project=project, admin=True + ) @action(detail=True) def environments(self, request, pk): @@ -50,28 +75,51 @@ def environments(self, request, pk): @swagger_auto_schema(responses={200: PermissionModelSerializer}) @action(detail=False, methods=["GET"]) def permissions(self, *args, **kwargs): - return Response(PermissionModelSerializer(instance=ProjectPermissionModel.objects.all(), many=True).data) + return Response( + PermissionModelSerializer( + instance=ProjectPermissionModel.objects.all(), many=True + ).data + ) @swagger_auto_schema(responses={200: MyUserObjectPermissionsSerializer}) - @action(detail=True, methods=["GET"], url_path="my-permissions", url_name="my-permissions") + @action( + detail=True, + methods=["GET"], + url_path="my-permissions", + url_name="my-permissions", + ) def user_permissions(self, request, *args, **kwargs): # TODO: tidy this mess up - group_permissions = UserPermissionGroupProjectPermission.objects.filter(group__users=request.user, - project=self.get_object()) - user_permissions = UserProjectPermission.objects.filter(user=request.user, project=self.get_object()) + group_permissions = UserPermissionGroupProjectPermission.objects.filter( + group__users=request.user, project=self.get_object() + ) + user_permissions = UserProjectPermission.objects.filter( + user=request.user, project=self.get_object() + ) permissions = set() for group_permission in group_permissions: permissions = permissions.union( - {permission.key for permission in group_permission.permissions.all() if permission.key}) + { + permission.key + for permission in group_permission.permissions.all() + if permission.key + } + ) for user_permission in user_permissions: permissions = permissions.union( - {permission.key for permission in user_permission.permissions.all() if permission.key}) + { + permission.key + for permission in user_permission.permissions.all() + if permission.key + } + ) data = { - 'admin': group_permissions.filter(admin=True).exists() or user_permissions.filter(admin=True).exists(), - 'permissions': permissions + "admin": group_permissions.filter(admin=True).exists() + or user_permissions.filter(admin=True).exists(), + "permissions": permissions, } serializer = MyUserObjectPermissionsSerializer(data=data) @@ -85,23 +133,25 @@ class UserProjectPermissionsViewSet(viewsets.ModelViewSet): permission_classes = [IsAuthenticated, NestedProjectPermissions] def get_queryset(self): - if not self.kwargs.get('project_pk'): - raise ValidationError('Missing project pk.') + if not self.kwargs.get("project_pk"): + raise ValidationError("Missing project pk.") - return UserProjectPermission.objects.filter(project__pk=self.kwargs['project_pk']) + return UserProjectPermission.objects.filter( + project__pk=self.kwargs["project_pk"] + ) def get_serializer_class(self): - if self.action == 'list': + if self.action == "list": return ListUserProjectPermissionSerializer return CreateUpdateUserProjectPermissionSerializer def perform_create(self, serializer): - project = Project.objects.get(id=self.kwargs['project_pk']) + project = Project.objects.get(id=self.kwargs["project_pk"]) serializer.save(project=project) def perform_update(self, serializer): - project = Project.objects.get(id=self.kwargs['project_pk']) + project = Project.objects.get(id=self.kwargs["project_pk"]) serializer.save(project=project) @@ -110,21 +160,23 @@ class UserPermissionGroupProjectPermissionsViewSet(viewsets.ModelViewSet): permission_classes = [IsAuthenticated, NestedProjectPermissions] def get_queryset(self): - if not self.kwargs.get('project_pk'): - raise ValidationError('Missing project pk.') + if not self.kwargs.get("project_pk"): + raise ValidationError("Missing project pk.") - return UserPermissionGroupProjectPermission.objects.filter(project__pk=self.kwargs['project_pk']) + return UserPermissionGroupProjectPermission.objects.filter( + project__pk=self.kwargs["project_pk"] + ) def get_serializer_class(self): - if self.action == 'list': + if self.action == "list": return ListUserPermissionGroupProjectPermissionSerializer return CreateUpdateUserPermissionGroupProjectPermissionSerializer def perform_create(self, serializer): - project = Project.objects.get(id=self.kwargs['project_pk']) + project = Project.objects.get(id=self.kwargs["project_pk"]) serializer.save(project=project) def perform_update(self, serializer): - project = Project.objects.get(id=self.kwargs['project_pk']) + project = Project.objects.get(id=self.kwargs["project_pk"]) serializer.save(project=project) diff --git a/src/sales_dashboard/apps.py b/src/sales_dashboard/apps.py index eaa67869c384..d06c198050c8 100644 --- a/src/sales_dashboard/apps.py +++ b/src/sales_dashboard/apps.py @@ -2,4 +2,4 @@ class SalesDashboardConfig(AppConfig): - name = 'sales_dashboard' + name = "sales_dashboard" diff --git a/src/sales_dashboard/urls.py b/src/sales_dashboard/urls.py index c82e7a95612b..06a82f41c5ef 100644 --- a/src/sales_dashboard/urls.py +++ b/src/sales_dashboard/urls.py @@ -4,7 +4,10 @@ from . import views urlpatterns = [ - path('', staff_member_required(views.OrganisationList.as_view()), name='index'), - path('organisations/', views.organisation_info, name='organistation_info'), - + path("", staff_member_required(views.OrganisationList.as_view()), name="index"), + path( + "organisations/", + views.organisation_info, + name="organistation_info", + ), ] diff --git a/src/sales_dashboard/views.py b/src/sales_dashboard/views.py index 3695c8d0fd8b..944e2d680c66 100644 --- a/src/sales_dashboard/views.py +++ b/src/sales_dashboard/views.py @@ -1,6 +1,9 @@ import json -from analytics.influxdb_wrapper import get_event_list_for_organisation, get_events_for_organisation +from analytics.influxdb_wrapper import ( + get_event_list_for_organisation, + get_events_for_organisation, +) from django.core.paginator import Paginator from django.contrib.admin.views.decorators import staff_member_required from django.db.models import Count @@ -10,35 +13,55 @@ from organisations.models import Organisation from django.shortcuts import get_object_or_404 from django.views.generic import ListView + OBJECTS_PER_PAGE = 50 class OrganisationList(ListView): model = Organisation paginate_by = OBJECTS_PER_PAGE - template_name = 'sales_dashboard/home.html' + template_name = "sales_dashboard/home.html" def get_queryset(self): - if 'search' in self.request.GET: - search_term = self.request.GET['search'] - organisations = Organisation.objects.annotate(projects_num=Count('projects')).annotate( - user_num=Count('users')).all().filter(name__icontains=search_term) + if "search" in self.request.GET: + search_term = self.request.GET["search"] + organisations = ( + Organisation.objects.annotate(projects_num=Count("projects")) + .annotate(user_num=Count("users")) + .all() + .filter(name__icontains=search_term) + ) else: - organisations = Organisation.objects.annotate(projects_num=Count('projects')).annotate( - user_num=Count('users')).all() + organisations = ( + Organisation.objects.annotate(projects_num=Count("projects")) + .annotate(user_num=Count("users")) + .all() + ) list_of_organisations = [] for organisation in organisations: - list_of_organisations.append({ - 'id': organisation.id, - 'name': organisation.name, - 'date_registered': organisation.created_date, - 'projects': organisation.projects_num, - 'users': organisation.user_num, - 'flags': sum([project.features.count() for project in organisation.projects.all()]), - 'segments': sum([project.segments.count() for project in organisation.projects.all()]), - }) + list_of_organisations.append( + { + "id": organisation.id, + "name": organisation.name, + "date_registered": organisation.created_date, + "projects": organisation.projects_num, + "users": organisation.user_num, + "flags": sum( + [ + project.features.count() + for project in organisation.projects.all() + ] + ), + "segments": sum( + [ + project.segments.count() + for project in organisation.projects.all() + ] + ), + } + ) return list_of_organisations @@ -46,13 +69,11 @@ def get_queryset(self): def organisation_info(request, organisation_id): organisation = get_object_or_404(Organisation, pk=organisation_id) event_list, labels = get_event_list_for_organisation(organisation_id) - template = loader.get_template('sales_dashboard/organisation.html') + template = loader.get_template("sales_dashboard/organisation.html") context = { - 'organisation': organisation, - 'event_list': mark_safe(json.dumps(event_list)), - 'labels': mark_safe(json.dumps(labels)) - + "organisation": organisation, + "event_list": mark_safe(json.dumps(event_list)), + "labels": mark_safe(json.dumps(labels)), } return HttpResponse(template.render(context, request)) - diff --git a/src/segments/admin.py b/src/segments/admin.py index 2e25b2119e10..03c3a1875072 100644 --- a/src/segments/admin.py +++ b/src/segments/admin.py @@ -1,7 +1,7 @@ from django.conf import settings from django.contrib import admin -from segments.models import SegmentRule, Condition, Segment +from segments.models import Condition, Segment, SegmentRule class RulesInline(admin.StackedInline): @@ -17,17 +17,13 @@ class ConditionsInline(admin.StackedInline): class SegmentAdmin(admin.ModelAdmin): - inlines = [ - RulesInline - ] + inlines = [RulesInline] class SegmentRuleAdmin(admin.ModelAdmin): - inlines = [ - ConditionsInline - ] + inlines = [ConditionsInline] -if settings.ENV == ('local', 'dev'): +if settings.ENV == ("local", "dev"): admin.site.register(Segment, SegmentAdmin) admin.site.register(SegmentRule, SegmentRuleAdmin) diff --git a/src/segments/apps.py b/src/segments/apps.py index 96eeb54b9b48..121c3e2aefa1 100644 --- a/src/segments/apps.py +++ b/src/segments/apps.py @@ -2,4 +2,4 @@ class SegmentsConfig(AppConfig): - name = 'segments' + name = "segments" diff --git a/src/segments/models.py b/src/segments/models.py index 66b539e2b728..4c1d5865c332 100644 --- a/src/segments/models.py +++ b/src/segments/models.py @@ -1,18 +1,17 @@ # -*- coding: utf-8 -*- -import re import hashlib +import re import typing from django.core.exceptions import ValidationError from django.db import models from django.utils.encoding import python_2_unicode_compatible -from environments.models import INTEGER, BOOLEAN, FLOAT -from environments.identities.traits.models import Trait from environments.identities.models import Identity +from environments.identities.traits.models import Trait +from environments.models import BOOLEAN, FLOAT, INTEGER from projects.models import Project - # Condition Types EQUAL = "EQUAL" GREATER_THAN = "GREATER_THAN" @@ -30,22 +29,28 @@ class Segment(models.Model): name = models.CharField(max_length=2000) description = models.TextField(null=True, blank=True) - project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="segments") + project = models.ForeignKey( + Project, on_delete=models.CASCADE, related_name="segments" + ) def __str__(self): return "Segment - %s" % self.name - def does_identity_match(self, identity: Identity, traits: typing.List[Trait] = None) -> bool: + def does_identity_match( + self, identity: Identity, traits: typing.List[Trait] = None + ) -> bool: rules = self.rules.all() - return rules.count() > 0 and all(rule.does_identity_match(identity, traits) for rule in rules) + return rules.count() > 0 and all( + rule.does_identity_match(identity, traits) for rule in rules + ) def get_identity_percentage_value(self, identity: Identity) -> float: """ Given a segment and an identity, generate a number between 0 and 1 to determine whether the identity falls within a given percentile when using percentage split rules. """ - to_hash = f'{self.id},{identity.id}' - hashed_value = hashlib.md5(to_hash.encode('utf-8')) + to_hash = f"{self.id},{identity.id}" + hashed_value = hashlib.md5(to_hash.encode("utf-8")) hashed_value_as_int = int(hashed_value.hexdigest(), base=16) return (hashed_value_as_int % 9999) / 9998 @@ -56,14 +61,14 @@ class SegmentRule(models.Model): ANY_RULE = "ANY" NONE_RULE = "NONE" - RULE_TYPES = ( - (ALL_RULE, "all"), - (ANY_RULE, "any"), - (NONE_RULE, "none") - ) + RULE_TYPES = ((ALL_RULE, "all"), (ANY_RULE, "any"), (NONE_RULE, "none")) - segment = models.ForeignKey(Segment, on_delete=models.CASCADE, related_name="rules", null=True, blank=True) - rule = models.ForeignKey('self', on_delete=models.CASCADE, related_name="rules", null=True, blank=True) + segment = models.ForeignKey( + Segment, on_delete=models.CASCADE, related_name="rules", null=True, blank=True + ) + rule = models.ForeignKey( + "self", on_delete=models.CASCADE, related_name="rules", null=True, blank=True + ) type = models.CharField(max_length=50, choices=RULE_TYPES) @@ -72,12 +77,19 @@ def clean(self): parents = [self.segment, self.rule] num_parents = sum(parent is not None for parent in parents) if num_parents != 1: - raise ValidationError("Segment rule must have exactly one parent, %d found", num_parents) + raise ValidationError( + "Segment rule must have exactly one parent, %d found", num_parents + ) def __str__(self): - return "%s rule for %s" % (self.type, str(self.segment) if self.segment else str(self.rule)) - - def does_identity_match(self, identity: Identity, traits: typing.List[Trait] = None) -> bool: + return "%s rule for %s" % ( + self.type, + str(self.segment) if self.segment else str(self.rule), + ) + + def does_identity_match( + self, identity: Identity, traits: typing.List[Trait] = None + ) -> bool: matches_conditions = False conditions = self.conditions.all() @@ -85,18 +97,23 @@ def does_identity_match(self, identity: Identity, traits: typing.List[Trait] = N matches_conditions = True elif self.type == self.ALL_RULE: matches_conditions = all( - condition.does_identity_match(identity, traits) for condition in conditions + condition.does_identity_match(identity, traits) + for condition in conditions ) elif self.type == self.ANY_RULE: matches_conditions = any( - condition.does_identity_match(identity, traits) for condition in conditions + condition.does_identity_match(identity, traits) + for condition in conditions ) elif self.type == self.NONE_RULE: matches_conditions = not any( - condition.does_identity_match(identity, traits) for condition in conditions + condition.does_identity_match(identity, traits) + for condition in conditions ) - return matches_conditions and all(rule.does_identity_match(identity, traits) for rule in self.rules.all()) + return matches_conditions and all( + rule.does_identity_match(identity, traits) for rule in self.rules.all() + ) def get_segment(self): """ @@ -123,19 +140,28 @@ class Condition(models.Model): (NOT_CONTAINS, "Does not contain"), (NOT_EQUAL, "Does not match"), (REGEX, "Matches regex"), - (PERCENTAGE_SPLIT, "Percentage split") + (PERCENTAGE_SPLIT, "Percentage split"), ) operator = models.CharField(choices=CONDITION_TYPES, max_length=500) property = models.CharField(blank=True, null=True, max_length=1000) value = models.CharField(max_length=1000) - rule = models.ForeignKey(SegmentRule, on_delete=models.CASCADE, related_name="conditions") + rule = models.ForeignKey( + SegmentRule, on_delete=models.CASCADE, related_name="conditions" + ) def __str__(self): - return "Condition for %s: %s %s %s" % (str(self.rule), self.property, self.operator, self.value) - - def does_identity_match(self, identity: Identity, traits: typing.List[Trait] = None) -> bool: + return "Condition for %s: %s %s %s" % ( + str(self.rule), + self.property, + self.operator, + self.value, + ) + + def does_identity_match( + self, identity: Identity, traits: typing.List[Trait] = None + ) -> bool: if self.operator == PERCENTAGE_SPLIT: return self._check_percentage_split_operator(identity) @@ -206,9 +232,9 @@ def check_float_value(self, value: float) -> bool: return False def check_boolean_value(self, value: bool) -> bool: - if self.value in ('False', 'false', '0'): + if self.value in ("False", "false", "0"): bool_value = False - elif self.value in ('True', 'true', '1'): + elif self.value in ("True", "true", "1"): bool_value = True else: return False diff --git a/src/segments/permissions.py b/src/segments/permissions.py index ea7af3e42b3f..64bd73dbf7ee 100644 --- a/src/segments/permissions.py +++ b/src/segments/permissions.py @@ -6,7 +6,7 @@ class SegmentPermissions(BasePermission): def has_permission(self, request, view): - project_pk = view.kwargs.get('project_pk') + project_pk = view.kwargs.get("project_pk") if not project_pk: return False @@ -16,12 +16,14 @@ def has_permission(self, request, view): return True # environment admins should be able to get segments for an identity - if 'identity' in request.query_params: - identity = Identity.objects.get(pk=request.query_params['identity']) + if "identity" in request.query_params: + identity = Identity.objects.get(pk=request.query_params["identity"]) if request.user.is_environment_admin(identity.environment): return True - if view.action == 'list' and request.user.has_project_permission('VIEW_PROJECT', project): + if view.action == "list" and request.user.has_project_permission( + "VIEW_PROJECT", project + ): # users with VIEW_PROJECT permission can list segments return True @@ -30,7 +32,7 @@ def has_permission(self, request, view): def has_object_permission(self, request, view, obj): project = obj.project - return ( - request.user.is_project_admin(project) or - (view.action == 'detail' and request.user.has_project_permission('VIEW_PROJECT', project)) + return request.user.is_project_admin(project) or ( + view.action == "detail" + and request.user.has_project_permission("VIEW_PROJECT", project) ) diff --git a/src/segments/serializers.py b/src/segments/serializers.py index f319ae62bfe0..3c5257ee7c04 100644 --- a/src/segments/serializers.py +++ b/src/segments/serializers.py @@ -1,28 +1,35 @@ from rest_framework import serializers from rest_framework.exceptions import ValidationError from rest_framework.serializers import ListSerializer - from rest_framework_recursive.fields import RecursiveField -from audit.models import AuditLog, RelatedObjectType, SEGMENT_CREATED_MESSAGE, SEGMENT_UPDATED_MESSAGE -from segments.models import Segment, SegmentRule, Condition +from audit.models import ( + SEGMENT_CREATED_MESSAGE, + SEGMENT_UPDATED_MESSAGE, + AuditLog, + RelatedObjectType, +) +from segments.models import Condition, Segment, SegmentRule + from . import models class ConditionSerializer(serializers.ModelSerializer): class Meta: model = models.Condition - fields = ('operator', 'property', 'value') + fields = ("operator", "property", "value") def validate(self, attrs): super(ConditionSerializer, self).validate(attrs) - if attrs.get('operator') != models.PERCENTAGE_SPLIT and not attrs.get('property'): - raise ValidationError({'property': ['This field may not be blank.']}) + if attrs.get("operator") != models.PERCENTAGE_SPLIT and not attrs.get( + "property" + ): + raise ValidationError({"property": ["This field may not be blank."]}) return attrs def to_internal_value(self, data): # convert value to a string - conversion to correct value type is handled elsewhere - data['value'] = str(data['value']) + data["value"] = str(data["value"]) return super(ConditionSerializer, self).to_internal_value(data) @@ -32,7 +39,7 @@ class RuleSerializer(serializers.ModelSerializer): class Meta: model = models.SegmentRule - fields = ('type', 'rules', 'conditions') + fields = ("type", "rules", "conditions") class SegmentSerializer(serializers.ModelSerializer): @@ -40,11 +47,13 @@ class SegmentSerializer(serializers.ModelSerializer): class Meta: model = models.Segment - fields = '__all__' + fields = "__all__" def validate(self, attrs): - if not attrs.get('rules'): - raise ValidationError({'rules': 'Segment cannot be created without any rules.'}) + if not attrs.get("rules"): + raise ValidationError( + {"rules": "Segment cannot be created without any rules."} + ) return attrs def create(self, validated_data): @@ -54,14 +63,14 @@ def create(self, validated_data): :param validated_data: validated json data :return: created Segment object """ - rules_data = validated_data.pop('rules', []) + rules_data = validated_data.pop("rules", []) segment = Segment.objects.create(**validated_data) self._create_segment_rules(rules_data, segment=segment) self._create_audit_log(segment, True) return segment def update(self, instance, validated_data): - rules_data = validated_data.pop('rules', []) + rules_data = validated_data.pop("rules", []) self._update_segment_rules(rules_data, segment=instance) self._create_audit_log(instance, False) return super().update(instance, validated_data) @@ -80,8 +89,8 @@ def _create_segment_rules(self, rules_data, segment=None, rule=None): raise RuntimeError("Can't create rule without parent segment or rule") for rule_data in rules_data: - child_rules = rule_data.pop('rules', []) - conditions = rule_data.pop('conditions', []) + child_rules = rule_data.pop("rules", []) + conditions = rule_data.pop("conditions", []) child_rule = self._create_segment_rule(rule_data, segment, rule) @@ -104,15 +113,22 @@ def _create_conditions(conditions_data, rule): Condition.objects.create(rule=rule, **condition) def _create_audit_log(self, instance, created): - message = SEGMENT_CREATED_MESSAGE % instance.name if created else SEGMENT_UPDATED_MESSAGE % instance.name - request = self.context.get('request') - AuditLog.objects.create(author=request.user if request else None, related_object_id=instance.id, - related_object_type=RelatedObjectType.SEGMENT.name, - project=instance.project, - log=message) + message = ( + SEGMENT_CREATED_MESSAGE % instance.name + if created + else SEGMENT_UPDATED_MESSAGE % instance.name + ) + request = self.context.get("request") + AuditLog.objects.create( + author=request.user if request else None, + related_object_id=instance.id, + related_object_type=RelatedObjectType.SEGMENT.name, + project=instance.project, + log=message, + ) class SegmentSerializerBasic(serializers.ModelSerializer): class Meta: model = Segment - fields = ('id', 'name', 'description') + fields = ("id", "name", "description") diff --git a/src/segments/tests/test_models.py b/src/segments/tests/test_models.py index 0d2baf98ea0d..4180adf469a1 100644 --- a/src/segments/tests/test_models.py +++ b/src/segments/tests/test_models.py @@ -3,28 +3,36 @@ import pytest import segments -from environments.models import Environment from environments.identities.models import Identity +from environments.models import Environment from organisations.models import Organisation from projects.models import Project -from segments.models import Segment, SegmentRule, PERCENTAGE_SPLIT, Condition +from segments.models import PERCENTAGE_SPLIT, Condition, Segment, SegmentRule @pytest.mark.django_db class SegmentTestCase(TestCase): def setUp(self) -> None: - self.organisation = Organisation.objects.create(name='Test Org') - self.project = Project.objects.create(name='Test Project', organisation=self.organisation) - self.environment = Environment.objects.create(name='Test Environment', project=self.project) - self.identity = Identity.objects.create(environment=self.environment, identifier='test_identity') + self.organisation = Organisation.objects.create(name="Test Org") + self.project = Project.objects.create( + name="Test Project", organisation=self.organisation + ) + self.environment = Environment.objects.create( + name="Test Environment", project=self.project + ) + self.identity = Identity.objects.create( + environment=self.environment, identifier="test_identity" + ) def tearDown(self) -> None: Segment.objects.all().delete() Identity.objects.all().delete() - def test_percentage_value_for_given_segment_rule_and_identity_is_number_between_0_and_1(self): + def test_percentage_value_for_given_segment_rule_and_identity_is_number_between_0_and_1( + self, + ): # Given - segment = Segment.objects.create(name='Test Segment', project=self.project) + segment = Segment.objects.create(name="Test Segment", project=self.project) # When result = segment.get_identity_percentage_value(self.identity) @@ -32,9 +40,11 @@ def test_percentage_value_for_given_segment_rule_and_identity_is_number_between_ # Then assert 1 >= result >= 0 - def test_percentage_value_for_given_segment_rule_and_identity_is_the_same_each_time(self): + def test_percentage_value_for_given_segment_rule_and_identity_is_the_same_each_time( + self, + ): # Given - segment = Segment.objects.create(name='Test Segment', project=self.project) + segment = Segment.objects.create(name="Test Segment", project=self.project) # When result_1 = segment.get_identity_percentage_value(self.identity) @@ -45,8 +55,10 @@ def test_percentage_value_for_given_segment_rule_and_identity_is_the_same_each_t def test_percentage_value_is_unique_for_different_identities(self): # Given - segment = Segment.objects.create(name='Test Segment', project=self.project) - another_identity = Identity.objects.create(environment=self.environment, identifier='another_test_identity') + segment = Segment.objects.create(name="Test Segment", project=self.project) + another_identity = Identity.objects.create( + environment=self.environment, identifier="another_test_identity" + ) # When result_1 = segment.get_identity_percentage_value(self.identity) @@ -70,40 +82,66 @@ def test_percentage_values_should_be_evenly_distributed(self): error_factor = 0.1 # Given - segment = Segment.objects.create(name='Test Segment', project=self.project) + segment = Segment.objects.create(name="Test Segment", project=self.project) identities = [] for i in range(test_sample): identities.append(Identity(environment=self.environment, identifier=str(i))) Identity.objects.bulk_create(identities) # When - values = [segment.get_identity_percentage_value(identity) for identity in Identity.objects.all()] + values = [ + segment.get_identity_percentage_value(identity) + for identity in Identity.objects.all() + ] values.sort() # Then for j in range(num_test_buckets): bucket_start = j * test_bucket_size bucket_end = (j + 1) * test_bucket_size - bucket_value_limit = min((j + 1) / num_test_buckets + error_factor * ((j + 1) / num_test_buckets), 1) + bucket_value_limit = min( + (j + 1) / num_test_buckets + + error_factor * ((j + 1) / num_test_buckets), + 1, + ) - assert all([value <= bucket_value_limit for value in values[bucket_start:bucket_end]]) + assert all( + [ + value <= bucket_value_limit + for value in values[bucket_start:bucket_end] + ] + ) @pytest.mark.django_db class SegmentRuleTest(TestCase): def setUp(self) -> None: - self.organisation = Organisation.objects.create(name='Test Org') - self.project = Project.objects.create(name='Test Project', organisation=self.organisation) - self.environment = Environment.objects.create(name='Test Environment', project=self.project) - self.identity = Identity.objects.create(environment=self.environment, identifier='test_identity') - self.segment = Segment.objects.create(project=self.project, name='test_segment') + self.organisation = Organisation.objects.create(name="Test Org") + self.project = Project.objects.create( + name="Test Project", organisation=self.organisation + ) + self.environment = Environment.objects.create( + name="Test Environment", project=self.project + ) + self.identity = Identity.objects.create( + environment=self.environment, identifier="test_identity" + ) + self.segment = Segment.objects.create(project=self.project, name="test_segment") def test_get_segment_returns_parent_segment_for_nested_rule(self): # Given - parent_rule = SegmentRule.objects.create(segment=self.segment, type=SegmentRule.ALL_RULE) - child_rule = SegmentRule.objects.create(rule=parent_rule, type=SegmentRule.ALL_RULE) - grandchild_rule = SegmentRule.objects.create(rule=child_rule, type=SegmentRule.ALL_RULE) - Condition.objects.create(operator=PERCENTAGE_SPLIT, value=0.1, rule=grandchild_rule) + parent_rule = SegmentRule.objects.create( + segment=self.segment, type=SegmentRule.ALL_RULE + ) + child_rule = SegmentRule.objects.create( + rule=parent_rule, type=SegmentRule.ALL_RULE + ) + grandchild_rule = SegmentRule.objects.create( + rule=child_rule, type=SegmentRule.ALL_RULE + ) + Condition.objects.create( + operator=PERCENTAGE_SPLIT, value=0.1, rule=grandchild_rule + ) # When segment = grandchild_rule.get_segment() @@ -115,17 +153,29 @@ def test_get_segment_returns_parent_segment_for_nested_rule(self): @pytest.mark.django_db class ConditionTest(TestCase): def setUp(self) -> None: - self.organisation = Organisation.objects.create(name='Test Org') - self.project = Project.objects.create(name='Test Project', organisation=self.organisation) - self.environment = Environment.objects.create(name='Test Environment', project=self.project) - self.identity = Identity.objects.create(environment=self.environment, identifier='test_identity') - self.segment = Segment.objects.create(project=self.project, name='test_segment') - self.rule = SegmentRule.objects.create(segment=self.segment, type=SegmentRule.ALL_RULE) - - @mock.patch.object(segments.models.Segment, 'get_identity_percentage_value') - def test_percentage_split_calculation_divides_value_by_100_before_comparison(self, get_identity_percentage_value): + self.organisation = Organisation.objects.create(name="Test Org") + self.project = Project.objects.create( + name="Test Project", organisation=self.organisation + ) + self.environment = Environment.objects.create( + name="Test Environment", project=self.project + ) + self.identity = Identity.objects.create( + environment=self.environment, identifier="test_identity" + ) + self.segment = Segment.objects.create(project=self.project, name="test_segment") + self.rule = SegmentRule.objects.create( + segment=self.segment, type=SegmentRule.ALL_RULE + ) + + @mock.patch.object(segments.models.Segment, "get_identity_percentage_value") + def test_percentage_split_calculation_divides_value_by_100_before_comparison( + self, get_identity_percentage_value + ): # Given - condition = Condition.objects.create(rule=self.rule, operator=PERCENTAGE_SPLIT, value=10) + condition = Condition.objects.create( + rule=self.rule, operator=PERCENTAGE_SPLIT, value=10 + ) get_identity_percentage_value.return_value = 0.2 # When diff --git a/src/segments/tests/test_permissions.py b/src/segments/tests/test_permissions.py index 9732c3b820d7..13481e87db89 100644 --- a/src/segments/tests/test_permissions.py +++ b/src/segments/tests/test_permissions.py @@ -2,8 +2,8 @@ import pytest -from environments.models import Environment from environments.identities.models import Identity +from environments.models import Environment from environments.permissions.models import UserEnvironmentPermission from organisations.models import Organisation from projects.models import Project, UserProjectPermission @@ -11,7 +11,6 @@ from segments.permissions import SegmentPermissions from users.models import FFAdminUser - mock_request = mock.MagicMock() mock_view = mock.MagicMock() @@ -21,21 +20,27 @@ @pytest.mark.django_db class SegmentPermissionsTestCase(TestCase): def setUp(self) -> None: - organisation = Organisation.objects.create(name='Test org') - self.project = Project.objects.create(name='Test project', organisation=organisation) - self.segment = Segment.objects.create(name='Test segment', project=self.project) + organisation = Organisation.objects.create(name="Test org") + self.project = Project.objects.create( + name="Test project", organisation=organisation + ) + self.segment = Segment.objects.create(name="Test segment", project=self.project) - self.project_admin = FFAdminUser.objects.create(email='project_admin@test.com') - mock_view.kwargs = {'project_pk': self.project.id} + self.project_admin = FFAdminUser.objects.create(email="project_admin@test.com") + mock_view.kwargs = {"project_pk": self.project.id} mock_request.query_params = {} - UserProjectPermission.objects.create(user=self.project_admin, admin=True, project=self.project) + UserProjectPermission.objects.create( + user=self.project_admin, admin=True, project=self.project + ) - self.project_user = FFAdminUser.objects.create(email='user@test.com') + self.project_user = FFAdminUser.objects.create(email="user@test.com") - user_project_permissions = UserProjectPermission.objects.create(project=self.project, user=self.project_user) - user_project_permissions.add_permission('VIEW_PROJECT') + user_project_permissions = UserProjectPermission.objects.create( + project=self.project, user=self.project_user + ) + user_project_permissions.add_permission("VIEW_PROJECT") def test_project_admin_has_permission(self): # Given @@ -43,7 +48,7 @@ def test_project_admin_has_permission(self): # When results = [] - for action in ('list', 'create'): + for action in ("list", "create"): mock_view.action = action results.append(segment_permissions.has_permission(mock_request, mock_view)) @@ -52,14 +57,20 @@ def test_project_admin_has_permission(self): def test_project_admin_has_object_permission(self): # Given - UserProjectPermission.objects.create(user=self.project_admin, project=self.project, admin=True) + UserProjectPermission.objects.create( + user=self.project_admin, project=self.project, admin=True + ) mock_request.user = self.project_admin # When results = [] - for action in ('update', 'destroy', 'retrieve'): + for action in ("update", "destroy", "retrieve"): mock_view.action = action - results.append(segment_permissions.has_object_permission(mock_request, mock_view, self.segment)) + results.append( + segment_permissions.has_object_permission( + mock_request, mock_view, self.segment + ) + ) # then assert all(results) @@ -70,7 +81,7 @@ def test_project_user_has_list_permission(self): mock_view.detail = False # When - mock_view.action = 'list' + mock_view.action = "list" result = segment_permissions.has_permission(mock_request, mock_view) # Then @@ -82,7 +93,7 @@ def test_project_user_has_no_create_permission(self): mock_view.detail = False # When - mock_view.action = 'create' + mock_view.action = "create" result = segment_permissions.has_permission(mock_request, mock_view) # Then @@ -94,20 +105,28 @@ def test_project_user_has_no_object_permission(self): # When results = [] - for action in ('retrieve', 'destroy', 'update'): + for action in ("retrieve", "destroy", "update"): mock_view.action = action - results.append(segment_permissions.has_object_permission(mock_request, mock_view, self.segment)) + results.append( + segment_permissions.has_object_permission( + mock_request, mock_view, self.segment + ) + ) # Then assert all(not result for result in results) def test_environment_admin_can_get_segments_for_an_identity(self): # Given - environment = Environment.objects.create(name='Test environment', project=self.project) - identity = Identity.objects.create(identifier='test', environment=environment) - user = FFAdminUser.objects.create(email='environment_admin@test.com') - UserEnvironmentPermission.objects.create(user=user, admin=True, environment=environment) - mock_request.query_params['identity'] = identity.id + environment = Environment.objects.create( + name="Test environment", project=self.project + ) + identity = Identity.objects.create(identifier="test", environment=environment) + user = FFAdminUser.objects.create(email="environment_admin@test.com") + UserEnvironmentPermission.objects.create( + user=user, admin=True, environment=environment + ) + mock_request.query_params["identity"] = identity.id # When result = segment_permissions.has_permission(mock_request, mock_view) diff --git a/src/segments/tests/test_views.py b/src/segments/tests/test_views.py index 2ede565c5a11..fe77de3c2757 100644 --- a/src/segments/tests/test_views.py +++ b/src/segments/tests/test_views.py @@ -5,127 +5,155 @@ from rest_framework import status from rest_framework.test import APITestCase -from audit.models import RelatedObjectType, AuditLog -from environments.models import Environment, STRING -from environments.identities.traits.models import Trait +from audit.models import AuditLog, RelatedObjectType from environments.identities.models import Identity +from environments.identities.traits.models import Trait +from environments.models import STRING, Environment from organisations.models import Organisation, OrganisationRole from projects.models import Project -from segments.models import Segment, SegmentRule, Condition, EQUAL +from segments.models import EQUAL, Condition, Segment, SegmentRule User = get_user_model() class SegmentViewSetTestCase(APITestCase): def setUp(self) -> None: - self.user = User.objects.create(email='test@example.com') - self.organisation = Organisation.objects.create(name='Test Organisation') + self.user = User.objects.create(email="test@example.com") + self.organisation = Organisation.objects.create(name="Test Organisation") self.user.add_organisation(self.organisation, OrganisationRole.ADMIN) self.client.force_authenticate(self.user) - self.project = Project.objects.create(name='Test project', organisation=self.organisation) + self.project = Project.objects.create( + name="Test project", organisation=self.organisation + ) def tearDown(self) -> None: AuditLog.objects.all().delete() def test_audit_log_created_when_segment_created(self): # Given - url = reverse('api-v1:projects:project-segments-list', args=[self.project.id]) + url = reverse("api-v1:projects:project-segments-list", args=[self.project.id]) data = { - 'name': 'Test Segment', - 'project': self.project.id, - 'rules': [{ - 'type': 'ALL', - 'rules': [], - 'conditions': [] - }] + "name": "Test Segment", + "project": self.project.id, + "rules": [{"type": "ALL", "rules": [], "conditions": []}], } # When - res = self.client.post(url, data=json.dumps(data), content_type='application/json') + res = self.client.post( + url, data=json.dumps(data), content_type="application/json" + ) # Then assert res.status_code == status.HTTP_201_CREATED - assert AuditLog.objects.filter(related_object_type=RelatedObjectType.SEGMENT.name).count() == 1 + assert ( + AuditLog.objects.filter( + related_object_type=RelatedObjectType.SEGMENT.name + ).count() + == 1 + ) def test_audit_log_created_when_segment_updated(self): # Given - segment = Segment.objects.create(name='Test segment', project=self.project) - url = reverse('api-v1:projects:project-segments-detail', args=[self.project.id, segment.id]) + segment = Segment.objects.create(name="Test segment", project=self.project) + url = reverse( + "api-v1:projects:project-segments-detail", + args=[self.project.id, segment.id], + ) data = { - 'name': 'New segment name', - 'project': self.project.id, - 'rules': [{ - 'type': 'ALL', - 'rules': [], - 'conditions': [] - }] + "name": "New segment name", + "project": self.project.id, + "rules": [{"type": "ALL", "rules": [], "conditions": []}], } # When - res = self.client.put(url, data=json.dumps(data), content_type='application/json') + res = self.client.put( + url, data=json.dumps(data), content_type="application/json" + ) # Then assert res.status_code == status.HTTP_200_OK - assert AuditLog.objects.filter(related_object_type=RelatedObjectType.SEGMENT.name).count() == 1 + assert ( + AuditLog.objects.filter( + related_object_type=RelatedObjectType.SEGMENT.name + ).count() + == 1 + ) def test_can_filter_by_identity_to_get_only_matching_segments(self): # Given - trait_key = 'trait_key' - trait_value = 'trait_value' - - matching_segment = Segment.objects.create(name='Matching segment', project=self.project) - matching_rule = SegmentRule.objects.create(segment=matching_segment, type=SegmentRule.ALL_RULE) - Condition.objects.create(rule=matching_rule, property=trait_key, operator=EQUAL, value=trait_value) - - Segment.objects.create(name='Non matching segment', project=self.project) - - environment = Environment.objects.create(name='Test environment', project=self.project) - identity = Identity.objects.create(identifier='test-user', environment=environment) - Trait.objects.create(identity=identity, trait_key=trait_key, value_type=STRING, string_value=trait_value) - - base_url = reverse('api-v1:projects:project-segments-list', args=[self.project.id]) - url = base_url + '?identity=%d' % identity.id + trait_key = "trait_key" + trait_value = "trait_value" + + matching_segment = Segment.objects.create( + name="Matching segment", project=self.project + ) + matching_rule = SegmentRule.objects.create( + segment=matching_segment, type=SegmentRule.ALL_RULE + ) + Condition.objects.create( + rule=matching_rule, property=trait_key, operator=EQUAL, value=trait_value + ) + + Segment.objects.create(name="Non matching segment", project=self.project) + + environment = Environment.objects.create( + name="Test environment", project=self.project + ) + identity = Identity.objects.create( + identifier="test-user", environment=environment + ) + Trait.objects.create( + identity=identity, + trait_key=trait_key, + value_type=STRING, + string_value=trait_value, + ) + + base_url = reverse( + "api-v1:projects:project-segments-list", args=[self.project.id] + ) + url = base_url + "?identity=%d" % identity.id # When res = self.client.get(url) # Then - assert res.json().get('count') == 1 + assert res.json().get("count") == 1 def test_cannot_create_segments_without_rules(self): # Given - url = reverse('api-v1:projects:project-segments-list', args=[self.project.id]) - data = { - 'name': 'New segment name', - 'project': self.project.id, - 'rules': [] - } + url = reverse("api-v1:projects:project-segments-list", args=[self.project.id]) + data = {"name": "New segment name", "project": self.project.id, "rules": []} # When - res = self.client.post(url, data=json.dumps(data), content_type='application/json') + res = self.client.post( + url, data=json.dumps(data), content_type="application/json" + ) # Then assert res.status_code == status.HTTP_400_BAD_REQUEST def test_can_create_segments_with_boolean_condition(self): # Given - url = reverse('api-v1:projects:project-segments-list', args=[self.project.id]) + url = reverse("api-v1:projects:project-segments-list", args=[self.project.id]) data = { - 'name': 'New segment name', - 'project': self.project.id, - 'rules': [{ - 'type': 'ALL', - 'rules': [], - 'conditions': [{ - 'operator': EQUAL, - 'property': 'test-property', - 'value': True - }] - }] + "name": "New segment name", + "project": self.project.id, + "rules": [ + { + "type": "ALL", + "rules": [], + "conditions": [ + {"operator": EQUAL, "property": "test-property", "value": True} + ], + } + ], } # When - res = self.client.post(url, data=json.dumps(data), content_type='application/json') + res = self.client.post( + url, data=json.dumps(data), content_type="application/json" + ) # Then assert res.status_code == status.HTTP_201_CREATED diff --git a/src/segments/views.py b/src/segments/views.py index 543b2dc51307..9409f82bd2f6 100644 --- a/src/segments/views.py +++ b/src/segments/views.py @@ -4,16 +4,17 @@ from django.utils.decorators import method_decorator from drf_yasg2 import openapi from drf_yasg2.utils import swagger_auto_schema -from rest_framework import viewsets, status +from rest_framework import status, viewsets from rest_framework.generics import get_object_or_404 from rest_framework.permissions import AllowAny from rest_framework.response import Response from environments.exceptions import EnvironmentHeaderNotPresentError -from environments.models import Environment from environments.identities.models import Identity +from environments.models import Environment from segments.serializers import SegmentSerializer from util.views import SDKAPIView + from . import serializers from .permissions import SegmentPermissions @@ -21,26 +22,37 @@ logger.setLevel(logging.INFO) -@method_decorator(name='list', decorator=swagger_auto_schema( - manual_parameters=[ - openapi.Parameter('identity', openapi.IN_QUERY, - 'Optionally provide the id of an identity to get only the segments they match', - required=False, type=openapi.TYPE_INTEGER) - ] -)) +@method_decorator( + name="list", + decorator=swagger_auto_schema( + manual_parameters=[ + openapi.Parameter( + "identity", + openapi.IN_QUERY, + "Optionally provide the id of an identity to get only the segments they match", + required=False, + type=openapi.TYPE_INTEGER, + ) + ] + ), +) class SegmentViewSet(viewsets.ModelViewSet): serializer_class = serializers.SegmentSerializer permission_classes = [SegmentPermissions] def get_queryset(self): - project = get_object_or_404(self.request.user.get_permitted_projects(['VIEW_PROJECT']), - pk=self.kwargs['project_pk']) + project = get_object_or_404( + self.request.user.get_permitted_projects(["VIEW_PROJECT"]), + pk=self.kwargs["project_pk"], + ) queryset = project.segments.all() - identity_pk = self.request.query_params.get('identity') + identity_pk = self.request.query_params.get("identity") if identity_pk: identity = Identity.objects.get(pk=identity_pk) - queryset = queryset.filter(id__in=[segment.id for segment in identity.get_segments()]) + queryset = queryset.filter( + id__in=[segment.id for segment in identity.get_segments()] + ) return queryset @@ -59,5 +71,7 @@ def get(self, request): error_response = {"error": "Environment not found for provided key"} return Response(error_response, status=status.HTTP_400_BAD_REQUEST) - return Response(self.get_serializer(environment.project.segments.all(), many=True).data, - status=status.HTTP_200_OK) + return Response( + self.get_serializer(environment.project.segments.all(), many=True).data, + status=status.HTTP_200_OK, + ) diff --git a/src/users/admin.py b/src/users/admin.py index 4d5457e1d8c6..750af4ce8021 100644 --- a/src/users/admin.py +++ b/src/users/admin.py @@ -9,41 +9,69 @@ class CustomUserAdmin(UserAdmin): model = FFAdminUser add_fieldsets = ( - (None, { - 'classes': ('wide',), - 'fields': ('email', 'password1', 'password2',), - }), + ( + None, + { + "classes": ("wide",), + "fields": ( + "email", + "password1", + "password2", + ), + }, + ), ) - date_hierarchy = 'date_joined' + date_hierarchy = "date_joined" list_display = ( - 'email', - 'username', - 'first_name', - 'last_name', - 'is_staff', - 'is_active', - 'date_joined', + "email", + "username", + "first_name", + "last_name", + "is_staff", + "is_active", + "date_joined", ) - list_filter = ('is_staff', 'is_active', 'date_joined', 'organisations', ) + list_filter = ( + "is_staff", + "is_active", + "date_joined", + "organisations", + ) - search_fields = ('email', 'username', 'first_name', 'last_name', ) + search_fields = ( + "email", + "username", + "first_name", + "last_name", + ) @admin.register(Invite) class InviteAdmin(admin.ModelAdmin): - date_hierarchy = 'date_created' - list_display = ('email', 'invited_by', 'organisation', 'date_created', ) - list_filter = ('date_created', 'organisation', ) - list_select_related = ('organisation', 'invited_by', ) - raw_id_fields = ('invited_by', ) + date_hierarchy = "date_created" + list_display = ( + "email", + "invited_by", + "organisation", + "date_created", + ) + list_filter = ( + "date_created", + "organisation", + ) + list_select_related = ( + "organisation", + "invited_by", + ) + raw_id_fields = ("invited_by",) search_fields = ( - 'email', - 'invited_by__email', - 'invited_by__username', - 'invited_by__first_name', - 'invited_by__last_name', - 'organisation__name', + "email", + "invited_by__email", + "invited_by__username", + "invited_by__first_name", + "invited_by__last_name", + "organisation__name", ) diff --git a/src/users/apps.py b/src/users/apps.py index 74d372bc0823..46ccdafb18aa 100644 --- a/src/users/apps.py +++ b/src/users/apps.py @@ -2,4 +2,4 @@ class AuthenticationConfig(AppConfig): - name = 'users' + name = "users" diff --git a/src/users/emails.py b/src/users/emails.py index 1be33a44ea04..0795f2de6221 100644 --- a/src/users/emails.py +++ b/src/users/emails.py @@ -5,11 +5,13 @@ class ActivationEmail(email.ActivationEmail): """ Overrides djoser Activation email with our own """ - template_name = 'users/activation.html' + + template_name = "users/activation.html" class ConfirmationEmail(email.ConfirmationEmail): """ Overrides djoser Confirmation email with our own """ + template_name = "users/confirmation.html" diff --git a/src/users/models.py b/src/users/models.py index a777cd3d5898..8179b3a53f12 100644 --- a/src/users/models.py +++ b/src/users/models.py @@ -11,11 +11,23 @@ from django.utils.translation import gettext_lazy as _ from app.utils import create_hash -from environments.models import Environment from environments.identities.models import Identity -from environments.permissions.models import UserEnvironmentPermission, UserPermissionGroupEnvironmentPermission -from organisations.models import Organisation, UserOrganisation, OrganisationRole, organisation_roles -from projects.models import UserProjectPermission, UserPermissionGroupProjectPermission, Project +from environments.models import Environment +from environments.permissions.models import ( + UserEnvironmentPermission, + UserPermissionGroupEnvironmentPermission, +) +from organisations.models import ( + Organisation, + OrganisationRole, + UserOrganisation, + organisation_roles, +) +from projects.models import ( + Project, + UserPermissionGroupProjectPermission, + UserProjectPermission, +) from users.auth_type import AuthType from users.exceptions import InvalidInviteError @@ -30,7 +42,7 @@ class UserManager(BaseUserManager): def _create_user(self, email, password, **extra_fields): """Create and save a User with the given email and password.""" if not email: - raise ValueError('The given email must be set') + raise ValueError("The given email must be set") email = self.normalize_email(email) user = self.model(email=email, **extra_fields) user.set_password(password) @@ -39,45 +51,42 @@ def _create_user(self, email, password, **extra_fields): def create_user(self, email, password=None, **extra_fields): """Create and save a regular User with the given email and password.""" - extra_fields.setdefault('is_staff', False) - extra_fields.setdefault('is_superuser', False) + extra_fields.setdefault("is_staff", False) + extra_fields.setdefault("is_superuser", False) return self._create_user(email, password, **extra_fields) def create_superuser(self, email, password, **extra_fields): """Create and save a SuperUser with the given email and password.""" - extra_fields.setdefault('is_staff', True) - extra_fields.setdefault('is_superuser', True) + extra_fields.setdefault("is_staff", True) + extra_fields.setdefault("is_superuser", True) - if extra_fields.get('is_staff') is not True: - raise ValueError('Superuser must have is_staff=True.') - if extra_fields.get('is_superuser') is not True: - raise ValueError('Superuser must have is_superuser=True.') + if extra_fields.get("is_staff") is not True: + raise ValueError("Superuser must have is_staff=True.") + if extra_fields.get("is_superuser") is not True: + raise ValueError("Superuser must have is_superuser=True.") return self._create_user(email, password, **extra_fields) @python_2_unicode_compatible class FFAdminUser(AbstractUser): - organisations = models.ManyToManyField(Organisation, related_name="users", blank=True, through=UserOrganisation) + organisations = models.ManyToManyField( + Organisation, related_name="users", blank=True, through=UserOrganisation + ) email = models.EmailField(unique=True, null=False) objects = UserManager() - username = models.CharField( - unique=True, - max_length=150, - null=True, - blank=True - ) - first_name = models.CharField(_('first name'), max_length=30) - last_name = models.CharField(_('last name'), max_length=150) + username = models.CharField(unique=True, max_length=150, null=True, blank=True) + first_name = models.CharField(_("first name"), max_length=30) + last_name = models.CharField(_("last name"), max_length=150) google_user_id = models.CharField(max_length=50, null=True, blank=True) github_user_id = models.CharField(max_length=50, null=True, blank=True) - USERNAME_FIELD = 'email' - REQUIRED_FIELDS = ['first_name', 'last_name'] + USERNAME_FIELD = "email" + REQUIRED_FIELDS = ["first_name", "last_name"] class Meta: - ordering = ['id'] - verbose_name = 'Feature flag admin user' + ordering = ["id"] + verbose_name = "Feature flag admin user" def __str__(self): return "%s %s" % (self.first_name, self.last_name) @@ -95,13 +104,13 @@ def auth_type(self): def get_full_name(self): if not self.first_name: return None - return ' '.join([self.first_name, self.last_name]).strip() + return " ".join([self.first_name, self.last_name]).strip() def join_organisation(self, invite): organisation = invite.organisation if invite.email.lower() != self.email.lower(): - raise InvalidInviteError('Registered email does not match invited email') + raise InvalidInviteError("Registered email does not match invited email") self.add_organisation(organisation, role=OrganisationRole(invite.role)) invite.delete() @@ -110,11 +119,15 @@ def is_admin(self, organisation): return self.get_organisation_role(organisation) == OrganisationRole.ADMIN.name def get_admin_organisations(self): - return Organisation.objects.filter(userorganisation__user=self, - userorganisation__role=OrganisationRole.ADMIN.name) + return Organisation.objects.filter( + userorganisation__user=self, + userorganisation__role=OrganisationRole.ADMIN.name, + ) def add_organisation(self, organisation, role=OrganisationRole.USER): - UserOrganisation.objects.create(user=self, organisation=organisation, role=role.name) + UserOrganisation.objects.create( + user=self, organisation=organisation, role=role.name + ) def remove_organisation(self, organisation): UserOrganisation.objects.filter(user=self, organisation=organisation).delete() @@ -133,7 +146,9 @@ def get_user_organisation(self, organisation): try: return self.userorganisation_set.get(organisation=organisation) except UserOrganisation.DoesNotExist: - logger.warning('User %d is not part of organisation %d' % (self.id, organisation.id)) + logger.warning( + "User %d is not part of organisation %d" % (self.id, organisation.id) + ) def get_permitted_projects(self, permissions): """ @@ -147,15 +162,25 @@ def get_permitted_projects(self, permissions): user_permission_query = Q() group_permission_query = Q() for permission in permissions: - user_permission_query = user_permission_query & Q(userpermission__permissions__key=permission) - group_permission_query = group_permission_query & Q(grouppermission__permissions__key=permission) + user_permission_query = user_permission_query & Q( + userpermission__permissions__key=permission + ) + group_permission_query = group_permission_query & Q( + grouppermission__permissions__key=permission + ) - user_query = Q(userpermission__user=self) & (user_permission_query | Q(userpermission__admin=True)) - group_query = Q(grouppermission__group__users=self) & (group_permission_query | Q(grouppermission__admin=True)) - organisation_query = Q(organisation__userorganisation__user=self, - organisation__userorganisation__role=OrganisationRole.ADMIN.name) + user_query = Q(userpermission__user=self) & ( + user_permission_query | Q(userpermission__admin=True) + ) + group_query = Q(grouppermission__group__users=self) & ( + group_permission_query | Q(grouppermission__admin=True) + ) + organisation_query = Q( + organisation__userorganisation__user=self, + organisation__userorganisation__role=OrganisationRole.ADMIN.name, + ) - query = (user_query | group_query | organisation_query) + query = user_query | group_query | organisation_query return Project.objects.filter(query).distinct() @@ -169,9 +194,14 @@ def is_project_admin(self, project): if self.is_admin(project.organisation): return True - return UserProjectPermission.objects.filter(admin=True, user=self, project=project).exists() or \ - UserPermissionGroupProjectPermission.objects.filter(group__users=self, admin=True, - project=project).exists() + return ( + UserProjectPermission.objects.filter( + admin=True, user=self, project=project + ).exists() + or UserPermissionGroupProjectPermission.objects.filter( + group__users=self, admin=True, project=project + ).exists() + ) def get_permitted_environments(self, permissions): """ @@ -185,23 +215,40 @@ def get_permitted_environments(self, permissions): user_permission_query = Q() group_permission_query = Q() for permission in permissions: - user_permission_query = user_permission_query & Q(userpermission__permissions__key=permission) - group_permission_query = group_permission_query & Q(grouppermission__permissions__key=permission) + user_permission_query = user_permission_query & Q( + userpermission__permissions__key=permission + ) + group_permission_query = group_permission_query & Q( + grouppermission__permissions__key=permission + ) - user_query = Q(userpermission__user=self) & (user_permission_query | Q(userpermission__admin=True)) - group_query = Q(grouppermission__group__users=self) & (group_permission_query | Q(grouppermission__admin=True)) - organisation_query = Q(project__organisation__userorganisation__user=self, - project__organisation__userorganisation__role=OrganisationRole.ADMIN.name) - project_admin_query = Q(project__userpermission__user=self, project__userpermission__admin=True) | Q( - project__grouppermission__group__users=self, project__grouppermission__admin=True) + user_query = Q(userpermission__user=self) & ( + user_permission_query | Q(userpermission__admin=True) + ) + group_query = Q(grouppermission__group__users=self) & ( + group_permission_query | Q(grouppermission__admin=True) + ) + organisation_query = Q( + project__organisation__userorganisation__user=self, + project__organisation__userorganisation__role=OrganisationRole.ADMIN.name, + ) + project_admin_query = Q( + project__userpermission__user=self, project__userpermission__admin=True + ) | Q( + project__grouppermission__group__users=self, + project__grouppermission__admin=True, + ) - query = (user_query | group_query | organisation_query | project_admin_query) + query = user_query | group_query | organisation_query | project_admin_query return Environment.objects.filter(query).distinct() def get_permitted_identities(self): return Identity.objects.filter( - environment__in=self.get_permitted_environments(permissions=['VIEW_ENVIRONMENT'])) + environment__in=self.get_permitted_environments( + permissions=["VIEW_ENVIRONMENT"] + ) + ) @staticmethod def send_alert_to_admin_users(subject, message): @@ -210,47 +257,68 @@ def send_alert_to_admin_users(subject, message): message=message, from_email=settings.DEFAULT_FROM_EMAIL, recipient_list=FFAdminUser._get_admin_user_emails(), - fail_silently=True + fail_silently=True, ) @classmethod def send_organisation_over_limit_alert(cls, organisation): cls.send_alert_to_admin_users( - subject='Organisation over number of seats', - message='Organisation %s has used %d seats which is over their plan limit of %d ' - '(plan: %s)' % (str(organisation.name), organisation.num_seats, organisation.subscription.max_seats, - organisation.subscription.plan) + subject="Organisation over number of seats", + message="Organisation %s has used %d seats which is over their plan limit of %d " + "(plan: %s)" + % ( + str(organisation.name), + organisation.num_seats, + organisation.subscription.max_seats, + organisation.subscription.plan, + ), ) @staticmethod def _get_admin_user_emails(): - return [user['email'] for user in FFAdminUser.objects.filter(is_staff=True).values('email')] + return [ + user["email"] + for user in FFAdminUser.objects.filter(is_staff=True).values("email") + ] def belongs_to(self, organisation_id: int) -> bool: - return organisation_id in self.organisations.all().values_list('id', flat=True) + return organisation_id in self.organisations.all().values_list("id", flat=True) def is_environment_admin(self, environment): - if self.is_admin(environment.project.organisation) or self.is_project_admin(environment.project): + if self.is_admin(environment.project.organisation) or self.is_project_admin( + environment.project + ): return True - return UserEnvironmentPermission.objects.filter(admin=True, user=self, environment=environment).exists() or \ - UserPermissionGroupEnvironmentPermission.objects.filter(group__users=self, admin=True, - environment=environment).exists() + return ( + UserEnvironmentPermission.objects.filter( + admin=True, user=self, environment=environment + ).exists() + or UserPermissionGroupEnvironmentPermission.objects.filter( + group__users=self, admin=True, environment=environment + ).exists() + ) @python_2_unicode_compatible class Invite(models.Model): email = models.EmailField() hash = models.CharField(max_length=100, default=create_hash, unique=True) - date_created = models.DateTimeField('DateCreated', auto_now_add=True) - organisation = models.ForeignKey(Organisation, on_delete=models.CASCADE, related_name='invites') + date_created = models.DateTimeField("DateCreated", auto_now_add=True) + organisation = models.ForeignKey( + Organisation, on_delete=models.CASCADE, related_name="invites" + ) frontend_base_url = models.CharField(max_length=500, null=False) - invited_by = models.ForeignKey(FFAdminUser, related_name='sent_invites', null=True, on_delete=models.CASCADE) - role = models.CharField(choices=organisation_roles, max_length=50, default=OrganisationRole.USER.name) + invited_by = models.ForeignKey( + FFAdminUser, related_name="sent_invites", null=True, on_delete=models.CASCADE + ) + role = models.CharField( + choices=organisation_roles, max_length=50, default=OrganisationRole.USER.name + ) class Meta: - unique_together = ('email', 'organisation') - ordering = ['organisation', 'date_created'] + unique_together = ("email", "organisation") + ordering = ["organisation", "date_created"] def save(self, *args, **kwargs): # send email invite before saving invite @@ -263,22 +331,25 @@ def get_invite_uri(self): def send_invite_mail(self): context = { "org_name": self.organisation.name, - "invite_url": self.get_invite_uri() + "invite_url": self.get_invite_uri(), } - html_template = get_template('users/invite_to_org.html') - plaintext_template = get_template('users/invite_to_org.txt') + html_template = get_template("users/invite_to_org.html") + plaintext_template = get_template("users/invite_to_org.txt") if self.invited_by: invited_by_name = self.invited_by.get_full_name() if not invited_by_name: invited_by_name = "A user" - subject = settings.EMAIL_CONFIGURATION.get('INVITE_SUBJECT_WITH_NAME') % ( - invited_by_name, self.organisation.name + subject = settings.EMAIL_CONFIGURATION.get("INVITE_SUBJECT_WITH_NAME") % ( + invited_by_name, + self.organisation.name, ) else: - subject = settings.EMAIL_CONFIGURATION.get('INVITE_SUBJECT_WITHOUT_NAME') % \ - self.organisation.name + subject = ( + settings.EMAIL_CONFIGURATION.get("INVITE_SUBJECT_WITHOUT_NAME") + % self.organisation.name + ) to = self.email @@ -287,8 +358,8 @@ def send_invite_mail(self): msg = EmailMultiAlternatives( subject, text_content, - settings.EMAIL_CONFIGURATION.get('INVITE_FROM_EMAIL'), - [to] + settings.EMAIL_CONFIGURATION.get("INVITE_FROM_EMAIL"), + [to], ) msg.attach_alternative(html_content, "text/html") msg.send() @@ -301,18 +372,27 @@ class UserPermissionGroup(models.Model): """ Model to group users within an organisation for the purposes of permissioning. """ + name = models.CharField(max_length=200) - users = models.ManyToManyField('users.FFAdminUser', related_name='permission_groups') - organisation = models.ForeignKey(Organisation, on_delete=models.CASCADE, related_name='permission_groups') + users = models.ManyToManyField( + "users.FFAdminUser", related_name="permission_groups" + ) + organisation = models.ForeignKey( + Organisation, on_delete=models.CASCADE, related_name="permission_groups" + ) def add_users_by_id(self, user_ids: list): users_to_add = [] for user_id in user_ids: try: - user = FFAdminUser.objects.get(id=user_id, organisations=self.organisation) + user = FFAdminUser.objects.get( + id=user_id, organisations=self.organisation + ) except FFAdminUser.DoesNotExist: # re-raise exception with useful error message - raise FFAdminUser.DoesNotExist('User %d does not exist in this organisation' % user_id) + raise FFAdminUser.DoesNotExist( + "User %d does not exist in this organisation" % user_id + ) users_to_add.append(user) self.users.add(*users_to_add) diff --git a/src/users/serializers.py b/src/users/serializers.py index 4aa3ed7ea069..a09e1bd5390c 100644 --- a/src/users/serializers.py +++ b/src/users/serializers.py @@ -4,6 +4,7 @@ from organisations.models import Organisation from organisations.serializers import UserOrganisationSerializer + from .models import FFAdminUser, Invite, UserPermissionGroup @@ -14,7 +15,7 @@ def update(self, instance, validated_data): pass def create(self, validated_data): - organisation = Organisation.objects.get(pk=self.context.get('organisation')) + organisation = Organisation.objects.get(pk=self.context.get("organisation")) user = self._get_user(validated_data) if user and organisation in user.organisations.all(): @@ -23,59 +24,65 @@ def create(self, validated_data): return user def validate(self, attrs): - if not FFAdminUser.objects.filter(pk=attrs.get('id')).exists(): - message = 'User with id %d does not exist' % attrs.get('id') - raise ValidationError({'id': message}) + if not FFAdminUser.objects.filter(pk=attrs.get("id")).exists(): + message = "User with id %d does not exist" % attrs.get("id") + raise ValidationError({"id": message}) return attrs def _get_user(self, validated_data): try: - return FFAdminUser.objects.get(pk=validated_data.get('id')) + return FFAdminUser.objects.get(pk=validated_data.get("id")) except FFAdminUser.DoesNotExist: return None class UserFullSerializer(serializers.ModelSerializer): - organisations = UserOrganisationSerializer(source='userorganisation_set', many=True) + organisations = UserOrganisationSerializer(source="userorganisation_set", many=True) class Meta: model = FFAdminUser - fields = ('id', 'email', 'first_name', 'last_name', 'organisations') + fields = ("id", "email", "first_name", "last_name", "organisations") class UserLoginSerializer(serializers.ModelSerializer): class Meta: model = FFAdminUser - fields = ('email', 'password') + fields = ("email", "password") class UserListSerializer(serializers.ModelSerializer): role = serializers.SerializerMethodField(read_only=True) join_date = serializers.SerializerMethodField(read_only=True) - default_fields = ('id', 'email', 'first_name', 'last_name') - organisation_users_fields = ('role', 'date_joined') + default_fields = ("id", "email", "first_name", "last_name") + organisation_users_fields = ("role", "date_joined") class Meta: model = FFAdminUser def get_field_names(self, declared_fields, info): fields = self.default_fields - if self.context.get('organisation'): + if self.context.get("organisation"): fields += self.organisation_users_fields return fields def get_role(self, instance): - return instance.get_organisation_role(self.context.get('organisation')) + return instance.get_organisation_role(self.context.get("organisation")) def get_join_date(self, instance): - return instance.get_organisation_join_date(self.context.get('organisation')) + return instance.get_organisation_join_date(self.context.get("organisation")) class InviteSerializer(serializers.ModelSerializer): class Meta: model = Invite - fields = ('email', 'organisation', 'frontend_base_url', 'invited_by', 'date_created') + fields = ( + "email", + "organisation", + "frontend_base_url", + "invited_by", + "date_created", + ) class InviteListSerializer(serializers.ModelSerializer): @@ -83,7 +90,7 @@ class InviteListSerializer(serializers.ModelSerializer): class Meta: model = Invite - fields = ('id', 'email', 'date_created', 'invited_by') + fields = ("id", "email", "date_created", "invited_by") class UserIdsSerializer(serializers.Serializer): @@ -93,8 +100,8 @@ class UserIdsSerializer(serializers.Serializer): class UserPermissionGroupSerializerList(serializers.ModelSerializer): class Meta: model = UserPermissionGroup - fields = ('id', 'name', 'users') - read_only_fields = ('id',) + fields = ("id", "name", "users") + read_only_fields = ("id",) class UserPermissionGroupSerializerDetail(UserPermissionGroupSerializerList): @@ -106,5 +113,4 @@ class CustomCurrentUserSerializer(DjoserUserSerializer): auth_type = serializers.CharField(read_only=True) class Meta(DjoserUserSerializer.Meta): - fields = DjoserUserSerializer.Meta.fields + ('auth_type',) - + fields = DjoserUserSerializer.Meta.fields + ("auth_type",) diff --git a/src/users/tests/test_models.py b/src/users/tests/test_models.py index e2ec1711dc75..dc92f40052d3 100644 --- a/src/users/tests/test_models.py +++ b/src/users/tests/test_models.py @@ -1,12 +1,23 @@ from unittest import TestCase import pytest - from django.db.utils import IntegrityError + from environments.models import Environment -from environments.permissions.models import EnvironmentPermissionModel, UserEnvironmentPermission -from organisations.models import Organisation, OrganisationRole, UserOrganisation -from projects.models import Project, UserProjectPermission, ProjectPermissionModel +from environments.permissions.models import ( + EnvironmentPermissionModel, + UserEnvironmentPermission, +) +from organisations.models import ( + Organisation, + OrganisationRole, + UserOrganisation, +) +from projects.models import ( + Project, + ProjectPermissionModel, + UserProjectPermission, +) from users.models import FFAdminUser @@ -16,11 +27,19 @@ def setUp(self) -> None: self.user = FFAdminUser.objects.create(email="test@example.com") self.organisation = Organisation.objects.create(name="Test Organisation") - self.project_1 = Project.objects.create(name='Test project 1', organisation=self.organisation) - self.project_2 = Project.objects.create(name='Test project 2', organisation=self.organisation) + self.project_1 = Project.objects.create( + name="Test project 1", organisation=self.organisation + ) + self.project_2 = Project.objects.create( + name="Test project 2", organisation=self.organisation + ) - self.environment_1 = Environment.objects.create(name='Test Environment 1', project=self.project_1) - self.environment_2 = Environment.objects.create(name='Test Environment 2', project=self.project_2) + self.environment_1 = Environment.objects.create( + name="Test Environment 1", project=self.project_1 + ) + self.environment_2 = Environment.objects.create( + name="Test Environment 2", project=self.project_2 + ) def test_user_belongs_to_success(self): self.user.add_organisation(self.organisation, OrganisationRole.USER) @@ -34,20 +53,26 @@ def test_get_permitted_projects_for_org_admin_returns_all_projects(self): self.user.add_organisation(self.organisation, OrganisationRole.ADMIN) # When - projects = self.user.get_permitted_projects(['VIEW_PROJECT', 'CREATE_ENVIRONMENT']) + projects = self.user.get_permitted_projects( + ["VIEW_PROJECT", "CREATE_ENVIRONMENT"] + ) # Then assert projects.count() == 2 - def test_get_permitted_projects_for_user_returns_only_projects_matching_permission(self): + def test_get_permitted_projects_for_user_returns_only_projects_matching_permission( + self, + ): # Given self.user.add_organisation(self.organisation, OrganisationRole.USER) - user_project_permission = UserProjectPermission.objects.create(user=self.user, project=self.project_1) - read_permission = ProjectPermissionModel.objects.get(key='VIEW_PROJECT') + user_project_permission = UserProjectPermission.objects.create( + user=self.user, project=self.project_1 + ) + read_permission = ProjectPermissionModel.objects.get(key="VIEW_PROJECT") user_project_permission.permissions.set([read_permission]) # When - projects = self.user.get_permitted_projects(permissions=['VIEW_PROJECT']) + projects = self.user.get_permitted_projects(permissions=["VIEW_PROJECT"]) # Then assert projects.count() == 1 @@ -67,20 +92,26 @@ def test_get_permitted_environments_for_org_admin_returns_all_environments(self) self.user.add_organisation(self.organisation, OrganisationRole.ADMIN) # When - environments = self.user.get_permitted_environments(['VIEW_ENVIRONMENT']) + environments = self.user.get_permitted_environments(["VIEW_ENVIRONMENT"]) # Then assert environments.count() == 2 - def test_get_permitted_environments_for_user_returns_only_environments_matching_permission(self): + def test_get_permitted_environments_for_user_returns_only_environments_matching_permission( + self, + ): # Given self.user.add_organisation(self.organisation, OrganisationRole.USER) - user_environment_permission = UserEnvironmentPermission.objects.create(user=self.user, environment=self.environment_1) - read_permission = EnvironmentPermissionModel.objects.get(key='VIEW_ENVIRONMENT') + user_environment_permission = UserEnvironmentPermission.objects.create( + user=self.user, environment=self.environment_1 + ) + read_permission = EnvironmentPermissionModel.objects.get(key="VIEW_ENVIRONMENT") user_environment_permission.permissions.set([read_permission]) # When - environments = self.user.get_permitted_environments(permissions=['VIEW_ENVIRONMENT']) + environments = self.user.get_permitted_environments( + permissions=["VIEW_ENVIRONMENT"] + ) # Then assert environments.count() == 1 diff --git a/src/users/tests/test_views.py b/src/users/tests/test_views.py index 689f2b4a0fbc..a950c09d9a0c 100644 --- a/src/users/tests/test_views.py +++ b/src/users/tests/test_views.py @@ -15,18 +15,17 @@ @pytest.mark.django_db class UserTestCase(TestCase): - auth_base_url = '/api/v1/auth/' - register_template = '{ ' \ - '"email": "%s", ' \ - '"first_name": "%s", ' \ - '"last_name": "%s", ' \ - '"password1": "%s", ' \ - '"password2": "%s" ' \ - '}' - login_template = '{' \ - '"email": "%s",' \ - '"password": "%s"' \ - '}' + auth_base_url = "/api/v1/auth/" + register_template = ( + "{ " + '"email": "%s", ' + '"first_name": "%s", ' + '"last_name": "%s", ' + '"password1": "%s", ' + '"password2": "%s" ' + "}" + ) + login_template = "{" '"email": "%s",' '"password": "%s"' "}" def setUp(self): self.client = APIClient() @@ -40,8 +39,10 @@ def tearDown(self) -> None: def test_join_organisation(self): # Given - invite = Invite.objects.create(email=self.user.email, organisation=self.organisation) - url = reverse('api-v1:users:user-join-organisation', args=[invite.hash]) + invite = Invite.objects.create( + email=self.user.email, organisation=self.organisation + ) + url = reverse("api-v1:users:user-join-organisation", args=[invite.hash]) # When response = self.client.post(url) @@ -54,9 +55,11 @@ def test_join_organisation(self): def test_user_can_join_second_organisation(self): # Given self.user.add_organisation(self.organisation) - new_organisation = Organisation.objects.create(name='New org') - invite = Invite.objects.create(email=self.user.email, organisation=new_organisation) - url = reverse('api-v1:users:user-join-organisation', args=[invite.hash]) + new_organisation = Organisation.objects.create(name="New org") + invite = Invite.objects.create( + email=self.user.email, organisation=new_organisation + ) + url = reverse("api-v1:users:user-join-organisation", args=[invite.hash]) # When response = self.client.post(url) @@ -64,12 +67,17 @@ def test_user_can_join_second_organisation(self): # Then assert response.status_code == status.HTTP_200_OK - assert new_organisation in self.user.organisations.all() and self.organisation in self.user.organisations.all() + assert ( + new_organisation in self.user.organisations.all() + and self.organisation in self.user.organisations.all() + ) def test_cannot_join_organisation_with_different_email_address_than_invite(self): # Given - invite = Invite.objects.create(email='some-other-email@test.com', organisation=self.organisation) - url = reverse('api-v1:users:user-join-organisation', args=[invite.hash]) + invite = Invite.objects.create( + email="some-other-email@test.com", organisation=self.organisation + ) + url = reverse("api-v1:users:user-join-organisation", args=[invite.hash]) # When res = self.client.post(url) @@ -82,9 +90,12 @@ def test_cannot_join_organisation_with_different_email_address_than_invite(self) def test_can_join_organisation_as_admin_if_invite_role_is_admin(self): # Given - invite = Invite.objects.create(email=self.user.email, organisation=self.organisation, - role=OrganisationRole.ADMIN.name) - url = reverse('api-v1:users:user-join-organisation', args=[invite.hash]) + invite = Invite.objects.create( + email=self.user.email, + organisation=self.organisation, + role=OrganisationRole.ADMIN.name, + ) + url = reverse("api-v1:users:user-join-organisation", args=[invite.hash]) # When self.client.post(url) @@ -92,33 +103,42 @@ def test_can_join_organisation_as_admin_if_invite_role_is_admin(self): # Then assert self.user.is_admin(self.organisation) - @mock.patch('users.views.Thread') - def test_join_organisation_alerts_admin_users_if_exceeds_plan_limit(self, MockThread): + @mock.patch("users.views.Thread") + def test_join_organisation_alerts_admin_users_if_exceeds_plan_limit( + self, MockThread + ): # Given Subscription.objects.create(organisation=self.organisation, max_seats=1) - invite = Invite.objects.create(email=self.user.email, organisation=self.organisation) - url = reverse('api-v1:users:user-join-organisation', args=[invite.hash]) - - existing_org_user = FFAdminUser.objects.create(email='existing_org_user@example.com') + invite = Invite.objects.create( + email=self.user.email, organisation=self.organisation + ) + url = reverse("api-v1:users:user-join-organisation", args=[invite.hash]) + + existing_org_user = FFAdminUser.objects.create( + email="existing_org_user@example.com" + ) existing_org_user.add_organisation(self.organisation, OrganisationRole.USER) # When self.client.post(url) # Then - MockThread.assert_called_with(target=FFAdminUser.send_organisation_over_limit_alert, args=[self.organisation]) + MockThread.assert_called_with( + target=FFAdminUser.send_organisation_over_limit_alert, + args=[self.organisation], + ) def test_admin_can_update_role_for_a_user_in_organisation(self): # Given self.user.add_organisation(self.organisation, OrganisationRole.ADMIN) - organisation_user = FFAdminUser.objects.create(email='org_user@org.com') + organisation_user = FFAdminUser.objects.create(email="org_user@org.com") organisation_user.add_organisation(self.organisation) - url = reverse('api-v1:organisations:organisation-users-update-role', args=[self.organisation.pk, - organisation_user.pk]) - data = { - 'role': OrganisationRole.ADMIN.name - } + url = reverse( + "api-v1:organisations:organisation-users-update-role", + args=[self.organisation.pk, organisation_user.pk], + ) + data = {"role": OrganisationRole.ADMIN.name} # When res = self.client.post(url, data=data) @@ -127,15 +147,20 @@ def test_admin_can_update_role_for_a_user_in_organisation(self): assert res.status_code == status.HTTP_200_OK # and - assert organisation_user.get_organisation_role(self.organisation) == OrganisationRole.ADMIN.name + assert ( + organisation_user.get_organisation_role(self.organisation) + == OrganisationRole.ADMIN.name + ) def test_admin_can_get_users_in_organisation(self): # Given self.user.add_organisation(self.organisation, OrganisationRole.ADMIN) - organisation_user = FFAdminUser.objects.create(email='org_user@org.com') + organisation_user = FFAdminUser.objects.create(email="org_user@org.com") organisation_user.add_organisation(self.organisation) - url = reverse('api-v1:organisations:organisation-users-list', args=[self.organisation.pk]) + url = reverse( + "api-v1:organisations:organisation-users-list", args=[self.organisation.pk] + ) # When res = self.client.get(url) @@ -147,9 +172,11 @@ def test_org_user_can_get_users_in_organisation(self): # Given self.user.add_organisation(self.organisation, OrganisationRole.USER) - organisation_user = FFAdminUser.objects.create(email='org_user@org.com') + organisation_user = FFAdminUser.objects.create(email="org_user@org.com") organisation_user.add_organisation(self.organisation) - url = reverse('api-v1:organisations:organisation-users-list', args=[self.organisation.pk]) + url = reverse( + "api-v1:organisations:organisation-users-list", args=[self.organisation.pk] + ) # When res = self.client.get(url) @@ -161,14 +188,18 @@ def test_org_user_can_get_users_in_organisation(self): @pytest.mark.django_db class UserPermissionGroupViewSetTestCase(TestCase): def setUp(self) -> None: - self.organisation = Organisation.objects.create(name='Test organisation') - self.admin = FFAdminUser.objects.create(email='admin@testorganisation.com') + self.organisation = Organisation.objects.create(name="Test organisation") + self.admin = FFAdminUser.objects.create(email="admin@testorganisation.com") self.admin.add_organisation(self.organisation, OrganisationRole.ADMIN) - self.regular_user = FFAdminUser.objects.create(email='user@testorganisation.com') + self.regular_user = FFAdminUser.objects.create( + email="user@testorganisation.com" + ) self.regular_user.add_organisation(self.organisation, OrganisationRole.USER) - self.list_url = reverse('api-v1:organisations:organisation-groups-list', args=[self.organisation.id]) + self.list_url = reverse( + "api-v1:organisations:organisation-groups-list", args=[self.organisation.id] + ) self.admin_user_client = APIClient() self.admin_user_client.force_authenticate(self.admin) @@ -178,126 +209,139 @@ def setUp(self) -> None: def _detail_url(self, permission_group_id: int) -> str: args = [self.organisation.id, permission_group_id] - return reverse('api-v1:organisations:organisation-groups-detail', args=args) + return reverse("api-v1:organisations:organisation-groups-detail", args=args) def _add_users_url(self, permission_group_id: int) -> str: args = [self.organisation.id, permission_group_id] - return reverse('api-v1:organisations:organisation-groups-add-users', args=args) + return reverse("api-v1:organisations:organisation-groups-add-users", args=args) def _remove_users_url(self, permission_group_id: int) -> str: args = [self.organisation.id, permission_group_id] - return reverse('api-v1:organisations:organisation-groups-remove-users', args=args) + return reverse( + "api-v1:organisations:organisation-groups-remove-users", args=args + ) def test_organisation_admin_can_interact_with_groups(self): client = self.admin_user_client # Create a group - create_data = { - 'name': 'Test Group' - } + create_data = {"name": "Test Group"} create_response = client.post(self.list_url, data=create_data) assert create_response.status_code == status.HTTP_201_CREATED - assert UserPermissionGroup.objects.filter(name=create_data['name']).exists() - group_id = create_response.json()['id'] + assert UserPermissionGroup.objects.filter(name=create_data["name"]).exists() + group_id = create_response.json()["id"] # Group appears in the groups list list_response = client.get(self.list_url) assert list_response.status_code == status.HTTP_200_OK - assert list_response.json()['results'][0]['name'] == 'Test Group' + assert list_response.json()["results"][0]["name"] == "Test Group" # update the group - update_data = { - 'name': 'New Group Name' - } + update_data = {"name": "New Group Name"} update_response = client.patch(self._detail_url(group_id), data=update_data) assert update_response.status_code == status.HTTP_200_OK # update is reflected when getting the group detail_response = client.get(self._detail_url(group_id)) assert detail_response.status_code == status.HTTP_200_OK - assert detail_response.json()['name'] == update_data['name'] + assert detail_response.json()["name"] == update_data["name"] # delete the group delete_response = client.delete(self._detail_url(group_id)) assert delete_response.status_code == status.HTTP_204_NO_CONTENT - assert not UserPermissionGroup.objects.filter(name=update_data['name']).exists() + assert not UserPermissionGroup.objects.filter(name=update_data["name"]).exists() def test_regular_user_cannot_interact_with_groups(self): client = self.regular_user_client - group_name = 'Test Group' - group = UserPermissionGroup.objects.create(name=group_name, organisation=self.organisation) - data = { - 'name': 'New Test Group' - } + group_name = "Test Group" + group = UserPermissionGroup.objects.create( + name=group_name, organisation=self.organisation + ) + data = {"name": "New Test Group"} responses = [ client.post(self.list_url, data=data), client.put(self._detail_url(group.id)), client.get(self._detail_url(group.id)), - client.delete(self._detail_url(group.id)) + client.delete(self._detail_url(group.id)), ] - assert all([response.status_code == status.HTTP_403_FORBIDDEN for response in responses]) + assert all( + [ + response.status_code == status.HTTP_403_FORBIDDEN + for response in responses + ] + ) assert UserPermissionGroup.objects.filter(name=group_name).exists() def test_can_add_multiple_users_including_current_user(self): # Given - group = UserPermissionGroup.objects.create(name='Test Group', organisation=self.organisation) + group = UserPermissionGroup.objects.create( + name="Test Group", organisation=self.organisation + ) url = self._add_users_url(group.id) - data = { - 'user_ids': [self.admin.id, self.regular_user.id] - } + data = {"user_ids": [self.admin.id, self.regular_user.id]} # When - response = self.admin_user_client.post(url, data=json.dumps(data), content_type='application/json') + response = self.admin_user_client.post( + url, data=json.dumps(data), content_type="application/json" + ) # Then assert response.status_code == status.HTTP_200_OK - assert all(user in group.users.all() for user in [self.admin, self.regular_user]) + assert all( + user in group.users.all() for user in [self.admin, self.regular_user] + ) def test_cannot_add_user_from_another_organisation(self): # Given - another_organisation = Organisation.objects.create(name='Another organisation') - another_user = FFAdminUser.objects.create(email='anotheruser@anotherorg.com') + another_organisation = Organisation.objects.create(name="Another organisation") + another_user = FFAdminUser.objects.create(email="anotheruser@anotherorg.com") another_user.add_organisation(another_organisation, role=OrganisationRole.USER) - group = UserPermissionGroup.objects.create(name='Test Group', organisation=self.organisation) + group = UserPermissionGroup.objects.create( + name="Test Group", organisation=self.organisation + ) url = self._add_users_url(group.id) - data = { - 'user_ids': [another_user.id] - } + data = {"user_ids": [another_user.id]} # When - response = self.admin_user_client.post(url, data=json.dumps(data), content_type='application/json') + response = self.admin_user_client.post( + url, data=json.dumps(data), content_type="application/json" + ) # Then assert response.status_code == status.HTTP_400_BAD_REQUEST def test_cannot_add_same_user_twice(self): # Given - group = UserPermissionGroup.objects.create(name='Test Group', organisation=self.organisation) + group = UserPermissionGroup.objects.create( + name="Test Group", organisation=self.organisation + ) group.users.add(self.regular_user) url = self._add_users_url(group.id) - data = { - 'user_ids': [self.regular_user.id] - } + data = {"user_ids": [self.regular_user.id]} # When - self.admin_user_client.post(url, data=json.dumps(data), content_type='application/json') + self.admin_user_client.post( + url, data=json.dumps(data), content_type="application/json" + ) # Then assert self.regular_user in group.users.all() and group.users.count() == 1 def test_remove_users(self): # Given - group = UserPermissionGroup.objects.create(name='Test Group', organisation=self.organisation) + group = UserPermissionGroup.objects.create( + name="Test Group", organisation=self.organisation + ) group.users.add(self.regular_user, self.admin) url = self._remove_users_url(group.id) - data = { - 'user_ids': [self.regular_user.id] - } + data = {"user_ids": [self.regular_user.id]} # When - self.admin_user_client.post(url, data=json.dumps(data), content_type='application/json') + self.admin_user_client.post( + url, data=json.dumps(data), content_type="application/json" + ) # Then # regular user has been removed @@ -308,15 +352,17 @@ def test_remove_users(self): def test_remove_users_silently_fails_if_user_not_in_group(self): # Given - group = UserPermissionGroup.objects.create(name='Test Group', organisation=self.organisation) + group = UserPermissionGroup.objects.create( + name="Test Group", organisation=self.organisation + ) group.users.add(self.admin) url = self._remove_users_url(group.id) - data = { - 'user_ids': [self.regular_user.id] - } + data = {"user_ids": [self.regular_user.id]} # When - response = self.admin_user_client.post(url, data=json.dumps(data), content_type='application/json') + response = self.admin_user_client.post( + url, data=json.dumps(data), content_type="application/json" + ) # Then # request was successful diff --git a/src/users/urls.py b/src/users/urls.py index 997b58ca162b..8bc08be9556e 100644 --- a/src/users/urls.py +++ b/src/users/urls.py @@ -6,7 +6,9 @@ app_name = "users" urlpatterns = [ - url(r'^join/(?P\w+)/', join_organisation, name='user-join-organisation'), + url( + r"^join/(?P\w+)/", join_organisation, name="user-join-organisation" + ), ] if settings.ALLOW_ADMIN_INITIATION_VIA_URL: - urlpatterns.insert(0, url(r'init/', AdminInitView.as_view())) + urlpatterns.insert(0, url(r"init/", AdminInitView.as_view())) diff --git a/src/users/views.py b/src/users/views.py index a392d76b17a3..c4b98ccfef4b 100644 --- a/src/users/views.py +++ b/src/users/views.py @@ -3,21 +3,30 @@ from django.conf import settings from django.contrib.sites.shortcuts import get_current_site from django.http import HttpResponse -from django.shortcuts import redirect, get_object_or_404 +from django.shortcuts import get_object_or_404, redirect from django.views import View from drf_yasg2.utils import swagger_auto_schema -from rest_framework import viewsets, status, mixins -from rest_framework.decorators import api_view, action +from rest_framework import mixins, status, viewsets +from rest_framework.decorators import action, api_view from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from organisations.models import Organisation -from organisations.permissions import OrganisationUsersPermission, \ - UserPermissionGroupPermission -from organisations.serializers import OrganisationSerializerFull, UserOrganisationSerializer +from organisations.permissions import ( + OrganisationUsersPermission, + UserPermissionGroupPermission, +) +from organisations.serializers import ( + OrganisationSerializerFull, + UserOrganisationSerializer, +) from users.exceptions import InvalidInviteError from users.models import FFAdminUser, Invite, UserPermissionGroup -from users.serializers import UserListSerializer, UserPermissionGroupSerializerDetail, UserIdsSerializer +from users.serializers import ( + UserIdsSerializer, + UserListSerializer, + UserPermissionGroupSerializerDetail, +) class AdminInitView(View): @@ -31,7 +40,9 @@ def get(self, request): admin.save() return HttpResponse("ADMIN USER CREATED") else: - return HttpResponse("FAILED TO INIT ADMIN USER. USER(S) ALREADY EXIST IN SYSTEM.") + return HttpResponse( + "FAILED TO INIT ADMIN USER. USER(S) ALREADY EXIST IN SYSTEM." + ) class FFAdminUserViewSet(mixins.ListModelMixin, viewsets.GenericViewSet): @@ -39,58 +50,75 @@ class FFAdminUserViewSet(mixins.ListModelMixin, viewsets.GenericViewSet): pagination_class = None def get_queryset(self): - if self.kwargs.get('organisation_pk'): - return FFAdminUser.objects.filter(organisations__id=self.kwargs.get('organisation_pk')) + if self.kwargs.get("organisation_pk"): + return FFAdminUser.objects.filter( + organisations__id=self.kwargs.get("organisation_pk") + ) else: return FFAdminUser.objects.none() def get_serializer_class(self, *args, **kwargs): - if self.action == 'update_role': + if self.action == "update_role": return UserOrganisationSerializer return UserListSerializer def get_serializer_context(self): context = super(FFAdminUserViewSet, self).get_serializer_context() - if self.kwargs.get('organisation_pk'): - context['organisation'] = Organisation.objects.get(pk=self.kwargs.get('organisation_pk')) + if self.kwargs.get("organisation_pk"): + context["organisation"] = Organisation.objects.get( + pk=self.kwargs.get("organisation_pk") + ) return context - @action(detail=True, methods=['POST'], url_path='update-role') + @action(detail=True, methods=["POST"], url_path="update-role") def update_role(self, request, organisation_pk, pk): user = self.get_object() organisation = Organisation.objects.get(pk=organisation_pk) user_organisation = user.get_user_organisation(organisation) - serializer = self.get_serializer(instance=user_organisation, data=request.data, partial=True) + serializer = self.get_serializer( + instance=user_organisation, data=request.data, partial=True + ) serializer.is_valid(raise_exception=True) serializer.save() - return Response(UserListSerializer(user, context={'organisation': organisation}).data) + return Response( + UserListSerializer(user, context={"organisation": organisation}).data + ) def password_reset_redirect(request, uidb64, token): protocol = "https" if request.is_secure() else "https" current_site = get_current_site(request) domain = current_site.domain - return redirect(protocol + "://" + domain + "/password-reset/" + uidb64 + "/" + token) + return redirect( + protocol + "://" + domain + "/password-reset/" + uidb64 + "/" + token + ) -@api_view(['POST']) +@api_view(["POST"]) def join_organisation(request, invite_hash): invite = get_object_or_404(Invite, hash=invite_hash) try: request.user.join_organisation(invite) except InvalidInviteError as e: - error_data = {'detail': str(e)} + error_data = {"detail": str(e)} return Response(data=error_data, status=status.HTTP_400_BAD_REQUEST) if invite.organisation.over_plan_seats_limit(): - Thread(target=FFAdminUser.send_organisation_over_limit_alert, args=[invite.organisation]).start() + Thread( + target=FFAdminUser.send_organisation_over_limit_alert, + args=[invite.organisation], + ).start() - return Response(OrganisationSerializerFull(invite.organisation, context={'request': request}).data, - status=status.HTTP_200_OK) + return Response( + OrganisationSerializerFull( + invite.organisation, context={"request": request} + ).data, + status=status.HTTP_200_OK, + ) class UserPermissionGroupViewSet(viewsets.ModelViewSet): @@ -98,29 +126,35 @@ class UserPermissionGroupViewSet(viewsets.ModelViewSet): serializer_class = UserPermissionGroupSerializerDetail def get_queryset(self): - organisation_pk = self.kwargs.get('organisation_pk') + organisation_pk = self.kwargs.get("organisation_pk") return UserPermissionGroup.objects.filter(organisation__pk=organisation_pk) def perform_create(self, serializer): - serializer.save(organisation_id=self.kwargs['organisation_pk']) + serializer.save(organisation_id=self.kwargs["organisation_pk"]) def perform_update(self, serializer): - serializer.save(organisation_id=self.kwargs['organisation_pk']) + serializer.save(organisation_id=self.kwargs["organisation_pk"]) - @swagger_auto_schema(request_body=UserIdsSerializer, responses={200: UserPermissionGroupSerializerDetail}) - @action(detail=True, methods=['POST'], url_path='add-users') + @swagger_auto_schema( + request_body=UserIdsSerializer, + responses={200: UserPermissionGroupSerializerDetail}, + ) + @action(detail=True, methods=["POST"], url_path="add-users") def add_users(self, request, organisation_pk, pk): group = self.get_object() try: - group.add_users_by_id(request.data['user_ids']) + group.add_users_by_id(request.data["user_ids"]) except FFAdminUser.DoesNotExist as e: - return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST) + return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST) return Response(UserPermissionGroupSerializerDetail(instance=group).data) - @swagger_auto_schema(request_body=UserIdsSerializer, responses={200: UserPermissionGroupSerializerDetail}) - @action(detail=True, methods=['POST'], url_path='remove-users') + @swagger_auto_schema( + request_body=UserIdsSerializer, + responses={200: UserPermissionGroupSerializerDetail}, + ) + @action(detail=True, methods=["POST"], url_path="remove-users") def remove_users(self, request, organisation_pk, pk): group = self.get_object() - group.remove_users_by_id(request.data['user_ids']) + group.remove_users_by_id(request.data["user_ids"]) return Response(UserPermissionGroupSerializerDetail(instance=group).data) diff --git a/src/util/history/custom_simple_history.py b/src/util/history/custom_simple_history.py index 3ec9de059965..2c573d88721c 100644 --- a/src/util/history/custom_simple_history.py +++ b/src/util/history/custom_simple_history.py @@ -8,6 +8,7 @@ class NonWritingHistoricalRecords(HistoricalRecords): being connected to the model. This allows us to stop the writing of records and then in a subsequent release, remove the database table. """ + def finalize(self, sender, **kwargs): # this should prevent the signals being added super(NonWritingHistoricalRecords, self).finalize(sender, **kwargs) diff --git a/src/util/logging.py b/src/util/logging.py index 3c9043521309..87cb33a32e28 100644 --- a/src/util/logging.py +++ b/src/util/logging.py @@ -6,4 +6,4 @@ def get_logger(name, level=None): logger = logging.getLogger(name) logger.setLevel(level or settings.LOG_LEVEL) - return logger \ No newline at end of file + return logger diff --git a/src/util/tests.py b/src/util/tests.py index 70f1f20629b0..759632ad4777 100644 --- a/src/util/tests.py +++ b/src/util/tests.py @@ -1,5 +1,5 @@ -from environments.models import Environment from environments.identities.models import Identity +from environments.models import Environment from features.models import Feature, FeatureState from organisations.models import Organisation from projects.models import Project @@ -11,12 +11,12 @@ def __init__(self): pass @staticmethod - def generate_database_models(identifier='user1'): - organisation = Organisation(name='ssg') + def generate_database_models(identifier="user1"): + organisation = Organisation(name="ssg") organisation.save() - project = Project(name='project1', organisation=organisation) + project = Project(name="project1", organisation=organisation) project.save() - environment = Environment(name='environment1', project=project) + environment = Environment(name="environment1", project=project) environment.save() feature = Feature(name="feature1", project=project) feature.save() @@ -36,8 +36,12 @@ def clean_up(): @staticmethod def create_ffadminuser(): Helper.clean_up() - user = FFAdminUser(username="test_user", email="test_user@test.com", - first_name="test", last_name="user") + user = FFAdminUser( + username="test_user", + email="test_user@test.com", + first_name="test", + last_name="user", + ) user.set_password("testuser123") user.save() - return user \ No newline at end of file + return user diff --git a/src/util/util.py b/src/util/util.py index bda59098378c..e0b61f5f61a1 100644 --- a/src/util/util.py +++ b/src/util/util.py @@ -6,4 +6,5 @@ def decorator(*args, **kwargs): t = Thread(target=function, args=args, kwargs=kwargs) t.daemon = True t.start() - return decorator \ No newline at end of file + + return decorator diff --git a/src/util/views.py b/src/util/views.py index d1b85dc588f7..70623b0f7ebf 100644 --- a/src/util/views.py +++ b/src/util/views.py @@ -6,4 +6,4 @@ class SDKAPIView(GenericAPIView): permission_classes = (EnvironmentKeyPermissions,) - authentication_classes = (EnvironmentKeyAuthentication,) \ No newline at end of file + authentication_classes = (EnvironmentKeyAuthentication,) diff --git a/src/webhooks/exceptions.py b/src/webhooks/exceptions.py index 8e2bf0d459fd..ebc480bc9377 100644 --- a/src/webhooks/exceptions.py +++ b/src/webhooks/exceptions.py @@ -1,2 +1,2 @@ class WebhookSendError(Exception): - pass \ No newline at end of file + pass diff --git a/src/webhooks/serializers.py b/src/webhooks/serializers.py index 252f43e31ddf..126ae1fd4524 100644 --- a/src/webhooks/serializers.py +++ b/src/webhooks/serializers.py @@ -4,4 +4,3 @@ class WebhookSerializer(serializers.Serializer): event_type = serializers.ChoiceField(choices=["FLAG_UPDATED", "AUDIT_LOG_CREATED"]) data = serializers.DictField() - diff --git a/src/webhooks/tests/test_webhooks.py b/src/webhooks/tests/test_webhooks.py index eefe81ee7000..96bd16f6bfef 100644 --- a/src/webhooks/tests/test_webhooks.py +++ b/src/webhooks/tests/test_webhooks.py @@ -5,24 +5,34 @@ from environments.models import Environment, Webhook from organisations.models import Organisation from projects.models import Project -from webhooks.webhooks import call_environment_webhooks, WebhookEventType +from webhooks.webhooks import WebhookEventType, call_environment_webhooks @pytest.mark.django_db class WebhooksTestCase(TestCase): def setUp(self) -> None: - organisation = Organisation.objects.create(name='Test organisation') - project = Project.objects.create(name='Test project', organisation=organisation) - self.environment = Environment.objects.create(name='Test environment', project=project) + organisation = Organisation.objects.create(name="Test organisation") + project = Project.objects.create(name="Test project", organisation=organisation) + self.environment = Environment.objects.create( + name="Test environment", project=project + ) - @mock.patch('webhooks.webhooks.requests') + @mock.patch("webhooks.webhooks.requests") def test_requests_made_to_all_urls_for_environment(self, mock_requests): # Given - webhook_1 = Webhook.objects.create(url='http://url.1.com', enabled=True, environment=self.environment) - webhook_2 = Webhook.objects.create(url='http://url.2.com', enabled=True, environment=self.environment) + webhook_1 = Webhook.objects.create( + url="http://url.1.com", enabled=True, environment=self.environment + ) + webhook_2 = Webhook.objects.create( + url="http://url.2.com", enabled=True, environment=self.environment + ) # When - call_environment_webhooks(environment=self.environment, data={}, event_type=WebhookEventType.FLAG_UPDATED) + call_environment_webhooks( + environment=self.environment, + data={}, + event_type=WebhookEventType.FLAG_UPDATED, + ) # Then assert len(mock_requests.post.call_args_list) == 2 @@ -31,15 +41,23 @@ def test_requests_made_to_all_urls_for_environment(self, mock_requests): call_1_args, _ = mock_requests.post.call_args_list[0] call_2_args, _ = mock_requests.post.call_args_list[1] all_call_args = call_1_args + call_2_args - assert all(str(webhook.url) in all_call_args for webhook in (webhook_1, webhook_2)) + assert all( + str(webhook.url) in all_call_args for webhook in (webhook_1, webhook_2) + ) - @mock.patch('webhooks.webhooks.requests') + @mock.patch("webhooks.webhooks.requests") def test_request_not_made_to_disabled_webhook(self, mock_requests): # Given - Webhook.objects.create(url='http://url.1.com', enabled=False, environment=self.environment) + Webhook.objects.create( + url="http://url.1.com", enabled=False, environment=self.environment + ) # When - call_environment_webhooks(environment=self.environment, data={}, event_type=WebhookEventType.FLAG_UPDATED) + call_environment_webhooks( + environment=self.environment, + data={}, + event_type=WebhookEventType.FLAG_UPDATED, + ) # Then mock_requests.post.assert_not_called() diff --git a/src/webhooks/webhooks.py b/src/webhooks/webhooks.py index bb4a1fd7183d..e024f4daf672 100644 --- a/src/webhooks/webhooks.py +++ b/src/webhooks/webhooks.py @@ -21,11 +21,21 @@ class WebhookType(enum.Enum): def call_environment_webhooks(environment, data, event_type): - _call_webhooks(environment.webhooks.filter(enabled=True), data, event_type, WebhookType.ENVIRONMENT) + _call_webhooks( + environment.webhooks.filter(enabled=True), + data, + event_type, + WebhookType.ENVIRONMENT, + ) def call_organisation_webhooks(organisation, data, event_type): - _call_webhooks(organisation.webhooks.filter(enabled=True), data, event_type, WebhookType.ORGANISATION) + _call_webhooks( + organisation.webhooks.filter(enabled=True), + data, + event_type, + WebhookType.ORGANISATION, + ) def _call_webhooks(webhooks, data, event_type, webhook_type): @@ -34,8 +44,10 @@ def _call_webhooks(webhooks, data, event_type, webhook_type): serializer.is_valid(raise_exception=False) for webhook in webhooks: try: - headers = {'content-type': 'application/json'} - json_data = json.dumps(serializer.data, sort_keys=True, cls=DjangoJSONEncoder) + headers = {"content-type": "application/json"} + json_data = json.dumps( + serializer.data, sort_keys=True, cls=DjangoJSONEncoder + ) res = requests.post(str(webhook.url), data=json_data, headers=headers) except requests.exceptions.ConnectionError: send_failure_email(webhook, serializer.data, webhook_type) @@ -46,17 +58,23 @@ def _call_webhooks(webhooks, data, event_type, webhook_type): def send_failure_email(webhook, data, webhook_type, status_code=None): - template_data = _get_failure_email_template_data(webhook, data, webhook_type, status_code) - organisation = webhook.organisation if webhook_type == WebhookType.ORGANISATION else webhook.environment.project.organisation + template_data = _get_failure_email_template_data( + webhook, data, webhook_type, status_code + ) + organisation = ( + webhook.organisation + if webhook_type == WebhookType.ORGANISATION + else webhook.environment.project.organisation + ) - text_template = get_template('features/webhook_failure.txt') + text_template = get_template("features/webhook_failure.txt") text_content = text_template.render(template_data) subject = "Bullet Train Webhook Failure" msg = EmailMultiAlternatives( subject, text_content, - settings.EMAIL_CONFIGURATION.get('INVITE_FROM_EMAIL'), - [organisation.webhook_notification_email] + settings.EMAIL_CONFIGURATION.get("INVITE_FROM_EMAIL"), + [organisation.webhook_notification_email], ) msg.content_subtype = "plain" msg.send() @@ -65,12 +83,8 @@ def send_failure_email(webhook, data, webhook_type, status_code=None): def _get_failure_email_template_data(webhook, data, webhook_type, status_code=None): data = { "status_code": status_code, - "data": json.dumps( - data, - sort_keys=True, - indent=2, cls=DjangoJSONEncoder - ), - "webhook_url": webhook.url + "data": json.dumps(data, sort_keys=True, indent=2, cls=DjangoJSONEncoder), + "webhook_url": webhook.url, } if webhook_type == WebhookType.ENVIRONMENT: From 7154831ea22c946a0bab11bb893939756d82f037 Mon Sep 17 00:00:00 2001 From: Ben Rometsch Date: Mon, 9 Nov 2020 16:43:30 +0000 Subject: [PATCH 21/47] Added SES credentials info --- readme.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/readme.md b/readme.md index 5326d89dfa36..96fef2eeb639 100644 --- a/readme.md +++ b/readme.md @@ -132,8 +132,10 @@ The application relies on the following environment variables to run: * `EMAIL_BACKEND`: email provider. Allowed values are `sgbackend.SendGridBackend` for Sendgrid or `django_ses.SESBackend` for Amazon SES. Defaults to `sgbackend.SendGridBackend`. * `SENDGRID_API_KEY`: API key for the Sendgrid account * `SENDER_EMAIL`: Email address from which emails are sent -* `AWS_SES_REGION_NAME`: If using Amazon SES as the email provider, specify the region (e.g. eu-central-1) that contains your verified sender e-mail address. Defaults to us-east-1 +* `AWS_SES_REGION_NAME`: If using Amazon SES as the email provider, specify the region (e.g. eu-central-1) that contains your verified sender e-mail address. Defaults to us-east-1 * `AWS_SES_REGION_ENDPOINT`: ses region endpoint, e.g. email.eu-central-1.amazonaws.com. Required when using ses in a region other than us-east-1 +* `AWS_ACCESS_KEY_ID`: If using Amazon SES, these form part of your SES credentials. +* `AWS_SECRET_ACCESS_KEY`: If using Amazon SES, these form part of your SES credentials. * `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 From 3372c72a495db14f9a14c688dc5e7dcf709034b5 Mon Sep 17 00:00:00 2001 From: Ben Rometsch Date: Sat, 14 Nov 2020 22:31:47 +0000 Subject: [PATCH 22/47] Rebrand readme update --- readme.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/readme.md b/readme.md index 96fef2eeb639..3ddd70e91180 100644 --- a/readme.md +++ b/readme.md @@ -1,8 +1,10 @@ -[Feature Flag, Remote Config and A/B Testing platform, Bullet Train](https://bullet-train.io/) +[Feature Flag, Remote Config and A/B Testing platform, Flagsmith](https://flagsmith.com/) [![Donate](https://liberapay.com/assets/widgets/donate.svg)](https://liberapay.com/Bullet-Train/donate) -# Bullet Train REST API +Bullet Train is now Flagsmith read about it [here](https://flagsmith.com/blog/rebrand). + +# Flagsmityh REST API ## Development Environment @@ -199,16 +201,16 @@ issue please search existing issues in order to prevent duplicates. ## Get in touch If you have any questions about our projects you can email -support@bullet-train.io. +support@flagsmith.com. ## Useful links -[Website](https://bullet-train.io) +[Website](https://www.flagsmith.com) [Product Roadmap](https://product-hub.io/roadmap/5d81f2406180537538d99f28) -[Documentation](https://docs.bullet-train.io/) +[Documentation](https://docs.flagsmith.com/) -[Code Examples](https://github.com/BulletTrainHQ/bullet-train-examples) +[Code Examples](https://github.com/Flagsmith/flagsmith-train-examples) [Youtube Tutorials](https://www.youtube.com/channel/UCki7GZrOdZZcsV9rAIRchCw) From 9de68f951db7f889ccd9f667f17d4e98fc78e857 Mon Sep 17 00:00:00 2001 From: Ben Rometsch Date: Sat, 14 Nov 2020 22:32:16 +0000 Subject: [PATCH 23/47] Typo --- readme.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/readme.md b/readme.md index 3ddd70e91180..e3ec3ddaea42 100644 --- a/readme.md +++ b/readme.md @@ -4,7 +4,7 @@ Bullet Train is now Flagsmith read about it [here](https://flagsmith.com/blog/rebrand). -# Flagsmityh REST API +# Flagsmith REST API ## Development Environment From 42eb8ee4ae3b57abff68e9dd31113ccddbbeaee9 Mon Sep 17 00:00:00 2001 From: Ben Rometsch Date: Sat, 14 Nov 2020 22:36:48 +0000 Subject: [PATCH 24/47] Updated hero --- hero.png | Bin 71475 -> 37372 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/hero.png b/hero.png index 04109656c771f236fb26d8ac01156528bd9c6550..78beaa7a07027432d6bb88abea6f171d4964196a 100644 GIT binary patch literal 37372 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 literal 71475 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�ww_IPUIc>cv8I|PVk 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 From 1ee3ce6b5ba9413dda45a0826eb59ff37c197bb8 Mon Sep 17 00:00:00 2001 From: Ben Rometsch Date: Wed, 18 Nov 2020 16:57:56 +0000 Subject: [PATCH 25/47] Why am I linting markdown --- readme.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/readme.md b/readme.md index e3ec3ddaea42..e54b873cf183 100644 --- a/readme.md +++ b/readme.md @@ -152,6 +152,18 @@ The application relies on the following environment variables to run: * `USER_CREATE_PERMISSIONS`: set the permissions for creating new users, using a comma separated list of djoser or rest_framework permissions. Use this to turn off public user creation for self hosting. e.g. `'djoser.permissions.CurrentUserOrAdmin'` Defaults to `'rest_framework.permissions.AllowAny'`. * `ENABLE_EMAIL_ACTIVATION`: new user registration will go via email activation flow, default False +### Creating a secret key + +It is important to also set an environment variable on whatever platform you are using for +`DJANGO_SECRET_KEY`. There is a function to create one in `app.settings.common` if none exists in +the environment variables, however, this is not suitable for use in production. To generate a new +secret key, you can use the function defined in `src/secret-key-gen.py` by simply running it from a +command prompt: + +```bash +python secret-key-gen.py +``` + ## Adding dependencies To add a python dependency, add it to requirements.txt / requirements-dev.txt with it's current version number. From 7f920d7566970a004725f2afe64cae54fd1f681f Mon Sep 17 00:00:00 2001 From: Ben Rometsch Date: Wed, 18 Nov 2020 10:17:56 +0000 Subject: [PATCH 26/47] Handle black library for builds --- Pipfile.lock | 1141 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1141 insertions(+) create mode 100644 Pipfile.lock diff --git a/Pipfile.lock b/Pipfile.lock new file mode 100644 index 000000000000..b1fad9b01170 --- /dev/null +++ b/Pipfile.lock @@ -0,0 +1,1141 @@ +{ + "_meta": { + "hash": { + "sha256": "5dc316c5f1c6d96ef50a52256ed643f5e3d7f449fb8117ca67bf02576e707b75" + }, + "pipfile-spec": 6, + "requires": {}, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.org/simple", + "verify_ssl": true + } + ] + }, + "default": { + "appdirs": { + "hashes": [ + "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41", + "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128" + ], + "index": "pypi", + "version": "==1.4.4" + }, + "asgiref": { + "hashes": [ + "sha256:5ee950735509d04eb673bd7f7120f8fa1c9e2df495394992c73234d526907e17", + "sha256:7162a3cb30ab0609f1a4c95938fd73e8604f63bdba516a7f7d64b83ff09478f0" + ], + "markers": "python_version >= '3.5'", + "version": "==3.3.1" + }, + "boto3": { + "hashes": [ + "sha256:0a4c72ef591fd4291efb9767a6a94f625afccc1e0fc7843968cd39eb6289c3e4", + "sha256:6c5d952f97e13997b1c7463038d96469355595cd37f87b43451759cc03756322" + ], + "version": "==1.16.20" + }, + "botocore": { + "hashes": [ + "sha256:00a69a507e8b817d0703c612131e6cb3a3260579d0353c56d005bd9effd92ec0", + "sha256:4576ca751264c65420daae07e244beafdfea493b4fbb815d37215c0736dc4633" + ], + "version": "==1.19.20" + }, + "cachetools": { + "hashes": [ + "sha256:513d4ff98dd27f85743a8dc0e92f55ddb1b49e060c2d5961512855cda2c01a98", + "sha256:bbaa39c3dede00175df2dc2b03d0cf18dd2d32a7de7beb68072d13043c9edb20" + ], + "markers": "python_version ~= '3.5'", + "version": "==4.1.1" + }, + "certifi": { + "hashes": [ + "sha256:1f422849db327d534e3d0c5f02a263458c3955ec0aae4ff09b95f195c59f4edd", + "sha256:f05def092c44fbf25834a51509ef6e631dc19765ab8a57b4e7ab85531f0a9cf4" + ], + "version": "==2020.11.8" + }, + "cffi": { + "hashes": [ + "sha256:005f2bfe11b6745d726dbb07ace4d53f057de66e336ff92d61b8c7e9c8f4777d", + "sha256:09e96138280241bd355cd585148dec04dbbedb4f46128f340d696eaafc82dd7b", + "sha256:0b1ad452cc824665ddc682400b62c9e4f5b64736a2ba99110712fdee5f2505c4", + "sha256:0ef488305fdce2580c8b2708f22d7785ae222d9825d3094ab073e22e93dfe51f", + "sha256:15f351bed09897fbda218e4db5a3d5c06328862f6198d4fb385f3e14e19decb3", + "sha256:22399ff4870fb4c7ef19fff6eeb20a8bbf15571913c181c78cb361024d574579", + "sha256:23e5d2040367322824605bc29ae8ee9175200b92cb5483ac7d466927a9b3d537", + "sha256:2791f68edc5749024b4722500e86303a10d342527e1e3bcac47f35fbd25b764e", + "sha256:2f9674623ca39c9ebe38afa3da402e9326c245f0f5ceff0623dccdac15023e05", + "sha256:3363e77a6176afb8823b6e06db78c46dbc4c7813b00a41300a4873b6ba63b171", + "sha256:33c6cdc071ba5cd6d96769c8969a0531be2d08c2628a0143a10a7dcffa9719ca", + "sha256:3b8eaf915ddc0709779889c472e553f0d3e8b7bdf62dab764c8921b09bf94522", + "sha256:3cb3e1b9ec43256c4e0f8d2837267a70b0e1ca8c4f456685508ae6106b1f504c", + "sha256:3eeeb0405fd145e714f7633a5173318bd88d8bbfc3dd0a5751f8c4f70ae629bc", + "sha256:44f60519595eaca110f248e5017363d751b12782a6f2bd6a7041cba275215f5d", + "sha256:4d7c26bfc1ea9f92084a1d75e11999e97b62d63128bcc90c3624d07813c52808", + "sha256:529c4ed2e10437c205f38f3691a68be66c39197d01062618c55f74294a4a4828", + "sha256:6642f15ad963b5092d65aed022d033c77763515fdc07095208f15d3563003869", + "sha256:85ba797e1de5b48aa5a8427b6ba62cf69607c18c5d4eb747604b7302f1ec382d", + "sha256:8f0f1e499e4000c4c347a124fa6a27d37608ced4fe9f7d45070563b7c4c370c9", + "sha256:a624fae282e81ad2e4871bdb767e2c914d0539708c0f078b5b355258293c98b0", + "sha256:b0358e6fefc74a16f745afa366acc89f979040e0cbc4eec55ab26ad1f6a9bfbc", + "sha256:bbd2f4dfee1079f76943767fce837ade3087b578aeb9f69aec7857d5bf25db15", + "sha256:bf39a9e19ce7298f1bd6a9758fa99707e9e5b1ebe5e90f2c3913a47bc548747c", + "sha256:c11579638288e53fc94ad60022ff1b67865363e730ee41ad5e6f0a17188b327a", + "sha256:c150eaa3dadbb2b5339675b88d4573c1be3cb6f2c33a6c83387e10cc0bf05bd3", + "sha256:c53af463f4a40de78c58b8b2710ade243c81cbca641e34debf3396a9640d6ec1", + "sha256:cb763ceceae04803adcc4e2d80d611ef201c73da32d8f2722e9d0ab0c7f10768", + "sha256:cc75f58cdaf043fe6a7a6c04b3b5a0e694c6a9e24050967747251fb80d7bce0d", + "sha256:d80998ed59176e8cba74028762fbd9b9153b9afc71ea118e63bbf5d4d0f9552b", + "sha256:de31b5164d44ef4943db155b3e8e17929707cac1e5bd2f363e67a56e3af4af6e", + "sha256:e66399cf0fc07de4dce4f588fc25bfe84a6d1285cc544e67987d22663393926d", + "sha256:f0620511387790860b249b9241c2f13c3a80e21a73e0b861a2df24e9d6f56730", + "sha256:f4eae045e6ab2bb54ca279733fe4eb85f1effda392666308250714e01907f394", + "sha256:f92cdecb618e5fa4658aeb97d5eb3d2f47aa94ac6477c6daf0f306c5a3b9e6b1", + "sha256:f92f789e4f9241cd262ad7a555ca2c648a98178a953af117ef7fad46aa1d5591" + ], + "version": "==1.14.3" + }, + "chardet": { + "hashes": [ + "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", + "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" + ], + "version": "==3.0.4" + }, + "chargebee": { + "hashes": [ + "sha256:95b1f7ff91737103f6115edb5f2329f525a4f181770597c6b9d2683628f9a92d" + ], + "index": "pypi", + "version": "==2.7.8" + }, + "coreapi": { + "hashes": [ + "sha256:46145fcc1f7017c076a2ef684969b641d18a2991051fddec9458ad3f78ffc1cb", + "sha256:bf39d118d6d3e171f10df9ede5666f63ad80bba9a29a8ec17726a66cf52ee6f3" + ], + "index": "pypi", + "version": "==2.3.3" + }, + "coreschema": { + "hashes": [ + "sha256:5e6ef7bf38c1525d5e55a895934ab4273548629f16aed5c0a6caa74ebf45551f", + "sha256:9503506007d482ab0867ba14724b93c18a33b22b6d19fb419ef2d239dd4a1607" + ], + "version": "==0.0.4" + }, + "cryptography": { + "hashes": [ + "sha256:07ca431b788249af92764e3be9a488aa1d39a0bc3be313d826bbec690417e538", + "sha256:13b88a0bd044b4eae1ef40e265d006e34dbcde0c2f1e15eb9896501b2d8f6c6f", + "sha256:32434673d8505b42c0de4de86da8c1620651abd24afe91ae0335597683ed1b77", + "sha256:3cd75a683b15576cfc822c7c5742b3276e50b21a06672dc3a800a2d5da4ecd1b", + "sha256:4e7268a0ca14536fecfdf2b00297d4e407da904718658c1ff1961c713f90fd33", + "sha256:545a8550782dda68f8cdc75a6e3bf252017aa8f75f19f5a9ca940772fc0cb56e", + "sha256:55d0b896631412b6f0c7de56e12eb3e261ac347fbaa5d5e705291a9016e5f8cb", + "sha256:5849d59358547bf789ee7e0d7a9036b2d29e9a4ddf1ce5e06bb45634f995c53e", + "sha256:6dc59630ecce8c1f558277ceb212c751d6730bd12c80ea96b4ac65637c4f55e7", + "sha256:7117319b44ed1842c617d0a452383a5a052ec6aa726dfbaffa8b94c910444297", + "sha256:75e8e6684cf0034f6bf2a97095cb95f81537b12b36a8fedf06e73050bb171c2d", + "sha256:7b8d9d8d3a9bd240f453342981f765346c87ade811519f98664519696f8e6ab7", + "sha256:a035a10686532b0587d58a606004aa20ad895c60c4d029afa245802347fab57b", + "sha256:a4e27ed0b2504195f855b52052eadcc9795c59909c9d84314c5408687f933fc7", + "sha256:a733671100cd26d816eed39507e585c156e4498293a907029969234e5e634bc4", + "sha256:a75f306a16d9f9afebfbedc41c8c2351d8e61e818ba6b4c40815e2b5740bb6b8", + "sha256:bd717aa029217b8ef94a7d21632a3bb5a4e7218a4513d2521c2a2fd63011e98b", + "sha256:d25cecbac20713a7c3bc544372d42d8eafa89799f492a43b79e1dfd650484851", + "sha256:d26a2557d8f9122f9bf445fc7034242f4375bd4e95ecda007667540270965b13", + "sha256:d3545829ab42a66b84a9aaabf216a4dce7f16dbc76eb69be5c302ed6b8f4a29b", + "sha256:d3d5e10be0cf2a12214ddee45c6bd203dab435e3d83b4560c03066eda600bfe3", + "sha256:efe15aca4f64f3a7ea0c09c87826490e50ed166ce67368a68f315ea0807a20df" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", + "version": "==3.2.1" + }, + "defusedxml": { + "hashes": [ + "sha256:6687150770438374ab581bb7a1b327a847dd9c5749e396102de3fad4e8a3ef93", + "sha256:f684034d135af4c6cbb949b8a4d2ed61634515257a67299e5f940fbaa34377f5" + ], + "markers": "python_version >= '3.0'", + "version": "==0.6.0" + }, + "dj-database-url": { + "hashes": [ + "sha256:4aeaeb1f573c74835b0686a2b46b85990571159ffc21aa57ecd4d1e1cb334163", + "sha256:851785365761ebe4994a921b433062309eb882fedd318e1b0fcecc607ed02da9" + ], + "index": "pypi", + "version": "==0.5.0" + }, + "django": { + "hashes": [ + "sha256:558cb27930defd9a6042133258caf797b2d1dee233959f537e3dc475cb49bd7c", + "sha256:cf5370a4d7765a9dd6d42a7b96b53c74f9446cd38209211304b210fe0404b861" + ], + "index": "pypi", + "version": "==2.2.17" + }, + "django-admin-sso": { + "hashes": [ + "sha256:1f11298f9a0fe7c34acfae057ae8e19637046fef0cd4b5b3fa0fcebb1e5526a9", + "sha256:32548dc797296642d4f7b51d37a48fd2fbfd938e2db01f738c4ef4acc16d1d5e" + ], + "index": "pypi", + "version": "==3.0.0" + }, + "django-axes": { + "hashes": [ + "sha256:25827cd722b006845e9abd5ecbfba92ebad3935a29258d457158212d4e1f0ded", + "sha256:320ddc577387b1b7926468b9313fd8048f86b70e35a02faa858cbac003900374" + ], + "index": "pypi", + "version": "==5.9.0" + }, + "django-cors-headers": { + "hashes": [ + "sha256:9322255c296d5f75089571f29e520c83ff9693df17aa3cf9f6a4bea7c6740169", + "sha256:db82b2840f667d47872ae3e4a4e0a0d72fbecb42779b8aa233fa8bb965f7836a" + ], + "index": "pypi", + "version": "==3.5.0" + }, + "django-debug-toolbar": { + "hashes": [ + "sha256:a1ce0665f7ef47d27b8df4b5d1058748e1f08500a01421a30d35164f38aaaf4c", + "sha256:c97921a9cd421d392e7860dc4b464db8e06c8628df4dc58fedab012888c293c6" + ], + "index": "pypi", + "version": "==3.1.1" + }, + "django-environ": { + "hashes": [ + "sha256:6c9d87660142608f63ec7d5ce5564c49b603ea8ff25da595fd6098f6dc82afde", + "sha256:c57b3c11ec1f319d9474e3e5a79134f40174b17c7cc024bbb2fad84646b120c4" + ], + "index": "pypi", + "version": "==0.4.5" + }, + "django-health-check": { + "hashes": [ + "sha256:2cb3944e313e435bdf299288e109f398b6c08b610e09cc90d7f5f6a2bcf469fc", + "sha256:8b0835f04ebaeb0d12498a5ef47dd22196237c3987ff28bcce9ed28b5a169d5e" + ], + "index": "pypi", + "version": "==3.16.1" + }, + "django-ipware": { + "hashes": [ + "sha256:c7df8e1410a8e5d6b1fbae58728402ea59950f043c3582e033e866f0f0cf5e94" + ], + "version": "==3.0.2" + }, + "django-ordered-model": { + "hashes": [ + "sha256:29af6624cf3505daaf0df00e2df1d0726dd777b95e08f304d5ad0264092aa934", + "sha256:d867166ed4dd12501139e119cbbc5b4d19798a3e72740aef0af4879ba97102cf" + ], + "index": "pypi", + "version": "==3.4.1" + }, + "django-ses": { + "hashes": [ + "sha256:45f041acb9f2f8df85f3fddd63b04f1d381982bdc7730a3dc1f88e5795758bdd", + "sha256:9c0a3e59e1e2424093820fa7cd519aa35f9ba978c26917a97a30c682449f0df6" + ], + "index": "pypi", + "version": "==1.0.3" + }, + "django-simple-history": { + "hashes": [ + "sha256:3b7bf6bfbcf973afca123c5786c72b917ed4d92d7bf3b6cb70fe2e3850e763a3", + "sha256:e7e830cb7a768dc90d6ba0507f8023f889bcb62fe31a08f18fac102c55eec539" + ], + "index": "pypi", + "version": "==2.12.0" + }, + "django-storages": { + "hashes": [ + "sha256:12de8fb2605b9b57bfaf54b075280d7cbb3b3ee1ca4bc9b9add147af87fe3a2c", + "sha256:652275ab7844538c462b62810276c0244866f345878256a9e0e86f5b1283ae18" + ], + "index": "pypi", + "version": "==1.10.1" + }, + "django-templated-mail": { + "hashes": [ + "sha256:8db807effebb42a532622e2d142dfd453dafcd0d7794c4c3332acb90656315f9", + "sha256:f7127e1e31d7cad4e6c4b4801d25814d4b8782627ead76f4a75b3b7650687556" + ], + "version": "==1.1.1" + }, + "django-trench": { + "hashes": [ + "sha256:63e189a057c45198d178ea79337e690250b484fcd8ff2057c9fd4b3699639853" + ], + "index": "pypi", + "version": "==0.2.3" + }, + "djangorestframework": { + "hashes": [ + "sha256:0209bafcb7b5010fdfec784034f059d512256424de2a0f084cb82b096d6dd6a7" + ], + "index": "pypi", + "version": "==3.12.2" + }, + "djangorestframework-recursive": { + "hashes": [ + "sha256:e4e51b26b7ee3c9f9b838885d638b91293e7c66e85b5955f278a6e10eb34ce7c", + "sha256:f8fc2d677ccb32fe53ec4153a45f66c822d0ce444824cba56edc76ca89b704ae" + ], + "index": "pypi", + "version": "==0.1.2" + }, + "djangorestframework-simplejwt": { + "hashes": [ + "sha256:7adc913ba0d2ed7f46e0b9bf6e86f9bd9248f1c4201722b732b8213e0ea66f9f", + "sha256:bd587700b6ab34a6c6b12d426cce4fa580d57ef1952ad4ba3b79707784619ed3" + ], + "markers": "python_version >= '3.7'", + "version": "==4.6.0" + }, + "djoser": { + "hashes": [ + "sha256:3299073aa5822f9ad02bc872b87e719051c07d36cdc87a05b2afdb2c3bad46d1", + "sha256:9590378d59eb3243572bcb6b0a45268a3e31bedddc15235ca248a18c7bc0ffe6" + ], + "index": "pypi", + "version": "==2.1.0" + }, + "drf-nested-routers": { + "hashes": [ + "sha256:52be428b046078ed21e9137167035cc2eb8daad036900fd2235ce026306e143a", + "sha256:e043fc937f94ac462a92d2d9fc9a7e55710a67164b558442adfe9634fc519c3b" + ], + "index": "pypi", + "version": "==0.92.1" + }, + "drf-yasg2": { + "hashes": [ + "sha256:7037a8041eb5d1073fa504a284fc889685f93d0bfd008a963db1b366db786734", + "sha256:75e661ca5cf15eb44fcfab408c7b864f87c20794f564aa08b3a31817a857f19d" + ], + "index": "pypi", + "version": "==1.19.4" + }, + "future": { + "hashes": [ + "sha256:b1bead90b70cf6ec3f0710ae53a525360fa360d306a86583adc6bf83a4db537d" + ], + "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==0.18.2" + }, + "google-api-core": { + "hashes": [ + "sha256:1bb3c485c38eacded8d685b1759968f6cf47dd9432922d34edb90359eaa391e2", + "sha256:94d8c707d358d8d9e8b0045c42be20efb58433d308bd92cf748511c7825569c8" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==1.23.0" + }, + "google-api-python-client": { + "hashes": [ + "sha256:1f5cfcb92c8e3bd0a69cb2ff3cefa5ff16ffa1900af795a53f5bed93c8238951", + "sha256:608552e52ea994a014be8bb0489923328a50776190e0858caaf7b186ebad22bf" + ], + "index": "pypi", + "version": "==1.12.6" + }, + "google-auth": { + "hashes": [ + "sha256:5176db85f1e7e837a646cd9cede72c3c404ccf2e3373d9ee14b2db88febad440", + "sha256:b728625ff5dfce8f9e56a499c8a4eb51443a67f20f6d28b67d5774c310ec4b6b" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==1.23.0" + }, + "google-auth-httplib2": { + "hashes": [ + "sha256:8d092cc60fb16517b12057ec0bba9185a96e3b7169d86ae12eae98e645b7bc39", + "sha256:aeaff501738b289717fac1980db9711d77908a6c227f60e4aa1923410b43e2ee" + ], + "version": "==0.0.4" + }, + "googleapis-common-protos": { + "hashes": [ + "sha256:560716c807117394da12cecb0a54da5a451b5cf9866f1d37e9a5e2329a665351", + "sha256:c8961760f5aad9a711d37b675be103e0cc4e9a39327e0d6d857872f698403e24" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==1.52.0" + }, + "gunicorn": { + "hashes": [ + "sha256:1904bb2b8a43658807108d59c3f3d56c2b6121a701161de0ddf9ad140073c626", + "sha256:cd4a810dd51bf497552cf3f863b575dabd73d6ad6a91075b65936b151cbf4f9c" + ], + "index": "pypi", + "version": "==20.0.4" + }, + "httplib2": { + "hashes": [ + "sha256:8af66c1c52c7ffe1aa5dc4bcd7c769885254b0756e6e69f953c7f0ab49a70ba3", + "sha256:ca2914b015b6247791c4866782fa6042f495b94401a0f0bd3e1d6e0ba2236782" + ], + "version": "==0.18.1" + }, + "idna": { + "hashes": [ + "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6", + "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==2.10" + }, + "inflection": { + "hashes": [ + "sha256:1a29730d366e996aaacffb2f1f1cb9593dc38e2ddd30c91250c6dde09ea9b417", + "sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2" + ], + "markers": "python_version >= '3.5'", + "version": "==0.5.1" + }, + "influxdb-client": { + "hashes": [ + "sha256:213cece87fbb71411c6d387d65e0cbc3d529bc37c734635e6ee4449443757331", + "sha256:f233da171a2508274d4cebba3ec66d2cc4729229e66419aabeb20220e3b36c82" + ], + "index": "pypi", + "version": "==1.12.0" + }, + "itypes": { + "hashes": [ + "sha256:03da6872ca89d29aef62773672b2d408f490f80db48b23079a4b194c86dd04c6", + "sha256:af886f129dea4a2a1e3d36595a2d139589e4dd287f5cab0b40e799ee81570ff1" + ], + "version": "==1.2.0" + }, + "jinja2": { + "hashes": [ + "sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0", + "sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", + "version": "==2.11.2" + }, + "jmespath": { + "hashes": [ + "sha256:b85d0567b8666149a93172712e68920734333c0ce7e89b78b3e987f71e5ed4f9", + "sha256:cdf6525904cc597730141d61b36f2e4b8ecc257c420fa2f4549bac2c2d0cb72f" + ], + "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==0.10.0" + }, + "markupsafe": { + "hashes": [ + "sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473", + "sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161", + "sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235", + "sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5", + "sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42", + "sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff", + "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b", + "sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1", + "sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e", + "sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183", + "sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66", + "sha256:596510de112c685489095da617b5bcbbac7dd6384aeebeda4df6025d0256a81b", + "sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1", + "sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15", + "sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1", + "sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e", + "sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b", + "sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905", + "sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735", + "sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d", + "sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e", + "sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d", + "sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c", + "sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21", + "sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2", + "sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5", + "sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b", + "sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6", + "sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f", + "sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f", + "sha256:cdb132fc825c38e1aeec2c8aa9338310d29d337bebbd7baa06889d09a60a1fa2", + "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7", + "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==1.1.1" + }, + "oauth2client": { + "hashes": [ + "sha256:b8a81cc5d60e2d364f0b1b98f958dbd472887acaf1a5b05e21c28c31a2d6d3ac", + "sha256:d486741e451287f69568a4d26d70d9acd73a2bbfa275746c535b4209891cccc6" + ], + "index": "pypi", + "version": "==4.1.3" + }, + "oauthlib": { + "hashes": [ + "sha256:bee41cc35fcca6e988463cacc3bcb8a96224f470ca547e697b604cc697b2f889", + "sha256:df884cd6cbe20e32633f1db1072e9356f53638e4361bef4e8b03c9127c9328ea" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==3.1.0" + }, + "packaging": { + "hashes": [ + "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8", + "sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181" + ], + "index": "pypi", + "version": "==20.4" + }, + "protobuf": { + "hashes": [ + "sha256:0e247612fadda953047f53301a7b0407cb0c3cb4ae25a6fde661597a04039b3c", + "sha256:0fc96785262042e4863b3f3b5c429d4636f10d90061e1840fce1baaf59b1a836", + "sha256:1c51fda1bbc9634246e7be6016d860be01747354ed7015ebe38acf4452f470d2", + "sha256:1d63eb389347293d8915fb47bee0951c7b5dab522a4a60118b9a18f33e21f8ce", + "sha256:22bcd2e284b3b1d969c12e84dc9b9a71701ec82d8ce975fdda19712e1cfd4e00", + "sha256:2a7e2fe101a7ace75e9327b9c946d247749e564a267b0515cf41dfe450b69bac", + "sha256:43b554b9e73a07ba84ed6cf25db0ff88b1e06be610b37656e292e3cbb5437472", + "sha256:4b74301b30513b1a7494d3055d95c714b560fbb630d8fb9956b6f27992c9f980", + "sha256:4e75105c9dfe13719b7293f75bd53033108f4ba03d44e71db0ec2a0e8401eafd", + "sha256:5b7a637212cc9b2bcf85dd828b1178d19efdf74dbfe1ddf8cd1b8e01fdaaa7f5", + "sha256:5e9806a43232a1fa0c9cf5da8dc06f6910d53e4390be1fa06f06454d888a9142", + "sha256:629b03fd3caae7f815b0c66b41273f6b1900a579e2ccb41ef4493a4f5fb84f3a", + "sha256:72230ed56f026dd664c21d73c5db73ebba50d924d7ba6b7c0d81a121e390406e", + "sha256:86a75477addde4918e9a1904e5c6af8d7b691f2a3f65587d73b16100fbe4c3b2", + "sha256:8971c421dbd7aad930c9bd2694122f332350b6ccb5202a8b7b06f3f1a5c41ed5", + "sha256:9616f0b65a30851e62f1713336c931fcd32c057202b7ff2cfbfca0fc7d5e3043", + "sha256:b0d5d35faeb07e22a1ddf8dce620860c8fe145426c02d1a0ae2688c6e8ede36d", + "sha256:ecc33531a213eee22ad60e0e2aaea6c8ba0021f0cce35dbf0ab03dee6e2a23a1" + ], + "version": "==3.14.0" + }, + "psycopg2-binary": { + "hashes": [ + "sha256:ad20d2eb875aaa1ea6d0f2916949f5c08a19c74d05b16ce6ebf6d24f2c9f75d1", + "sha256:e1f57aa70d3f7cc6947fd88636a481638263ba04a742b4a37dd25c373e41491a", + "sha256:833709a5c66ca52f1d21d41865a637223b368c0ee76ea54ca5bad6f2526c7679", + "sha256:11b9c0ebce097180129e422379b824ae21c8f2a6596b159c7659e2e5a00e1aa0", + "sha256:aaa4213c862f0ef00022751161df35804127b78adf4a2755b9f991a507e425fd", + "sha256:ba28584e6bca48c59eecbf7efb1576ca214b47f05194646b081717fa628dfddf", + "sha256:ac0c682111fbf404525dfc0f18a8b5f11be52657d4f96e9fcb75daf4f3984859", + "sha256:6422f2ff0919fd720195f64ffd8f924c1395d30f9a495f31e2392c2efafb5056", + "sha256:0deac2af1a587ae12836aa07970f5cb91964f05a7c6cdb69d8425ff4c15d4e2c", + "sha256:bd1be66dde2b82f80afb9459fc618216753f67109b859a361cf7def5c7968729", + "sha256:1fabed9ea2acc4efe4671b92c669a213db744d2af8a9fc5d69a8e9bc14b7a9db", + "sha256:d1b4ab59e02d9008efe10ceabd0b31e79519da6fb67f7d8e8977118832d0f449", + "sha256:b8a3715b3c4e604bcc94c90a825cd7f5635417453b253499664f784fc4da0152", + "sha256:d14b140a4439d816e3b1229a4a525df917d6ea22a0771a2a78332273fd9528a4", + "sha256:ee69dad2c7155756ad114c02db06002f4cded41132cc51378e57aad79cc8e4f4", + "sha256:7312e931b90fe14f925729cde58022f5d034241918a5c4f9797cac62f6b3a9dd", + "sha256:15978a1fbd225583dd8cdaf37e67ccc278b5abecb4caf6b2d6b8e2b948e953f6", + "sha256:d5227b229005a696cc67676e24c214740efd90b148de5733419ac9aaba3773da", + "sha256:a0c50db33c32594305b0ef9abc0cb7db13de7621d2cadf8392a1d9b3c437ef77", + "sha256:e74a55f6bad0e7d3968399deb50f61f4db1926acf4a6d83beaaa7df986f48b1c", + "sha256:e82aba2188b9ba309fd8e271702bd0d0fc9148ae3150532bbb474f4590039ffb", + "sha256:8cd0fb36c7412996859cb4606a35969dd01f4ea34d9812a141cd920c3b18be77", + "sha256:89705f45ce07b2dfa806ee84439ec67c5d9a0ef20154e0e475e2b2ed392a5b83", + "sha256:2dac98e85565d5688e8ab7bdea5446674a83a3945a8f416ad0110018d1501b94", + "sha256:ba381aec3a5dc29634f20692349d73f2d21f17653bda1decf0b52b11d694541f", + "sha256:7d92a09b788cbb1aec325af5fcba9fed7203897bbd9269d5691bb1e3bce29550", + "sha256:c2507d796fca339c8fb03216364cca68d87e037c1f774977c8fc377627d01c71", + "sha256:a0eb43a07386c3f1f1ebb4dc7aafb13f67188eab896e7397aa1ee95a9c884eb2", + "sha256:b4afc542c0ac0db720cf516dd20c0846f71c248d2b3d21013aa0d4ef9c71ca25", + "sha256:f5ab93a2cb2d8338b1674be43b442a7f544a0971da062a5da774ed40587f18f5", + "sha256:950bc22bb56ee6ff142a2cb9ee980b571dd0912b0334aa3fe0fe3788d860bea2", + "sha256:42ec1035841b389e8cc3692277a0bd81cdfe0b65d575a2c8862cec7a80e62e52", + "sha256:6a32f3a4cb2f6e1a0b15215f448e8ce2da192fd4ff35084d80d5e39da683e79b", + "sha256:cec7e622ebc545dbb4564e483dd20e4e404da17ae07e06f3e780b2dacd5cee66", + "sha256:0e4dc3d5996760104746e6cfcdb519d9d2cd27c738296525d5867ea695774e67" + ], + "index": "pypi", + "version": "==2.8.6" + }, + "pyasn1": { + "hashes": [ + "sha256:014c0e9976956a08139dc0712ae195324a75e142284d5f87f1a87ee1b068a359", + "sha256:03840c999ba71680a131cfaee6fab142e1ed9bbd9c693e285cc6aca0d555e576", + "sha256:0458773cfe65b153891ac249bcf1b5f8f320b7c2ce462151f8fa74de8934becf", + "sha256:08c3c53b75eaa48d71cf8c710312316392ed40899cb34710d092e96745a358b7", + "sha256:39c7e2ec30515947ff4e87fb6f456dfc6e84857d34be479c9d4a4ba4bf46aa5d", + "sha256:5c9414dcfede6e441f7e8f81b43b34e834731003427e5b09e4e00e3172a10f00", + "sha256:6e7545f1a61025a4e58bb336952c5061697da694db1cae97b116e9c46abcf7c8", + "sha256:78fa6da68ed2727915c4767bb386ab32cdba863caa7dbe473eaae45f9959da86", + "sha256:7ab8a544af125fb704feadb008c99a88805126fb525280b2270bb25cc1d78a12", + "sha256:99fcc3c8d804d1bc6d9a099921e39d827026409a58f2a720dcdb89374ea0c776", + "sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba", + "sha256:e89bf84b5437b532b0803ba5c9a5e054d21fec423a89952a74f87fa2c9b7bce2", + "sha256:fec3e9d8e36808a28efb59b489e4528c10ad0f480e57dcc32b4de5c9d8c9fdf3" + ], + "version": "==0.4.8" + }, + "pyasn1-modules": { + "hashes": [ + "sha256:0845a5582f6a02bb3e1bde9ecfc4bfcae6ec3210dd270522fee602365430c3f8", + "sha256:0fe1b68d1e486a1ed5473f1302bd991c1611d319bba158e98b106ff86e1d7199", + "sha256:15b7c67fabc7fc240d87fb9aabf999cf82311a6d6fb2c70d00d3d0604878c811", + "sha256:426edb7a5e8879f1ec54a1864f16b882c2837bfd06eee62f2c982315ee2473ed", + "sha256:65cebbaffc913f4fe9e4808735c95ea22d7a7775646ab690518c056784bc21b4", + "sha256:905f84c712230b2c592c19470d3ca8d552de726050d1d1716282a1f6146be65e", + "sha256:a50b808ffeb97cb3601dd25981f6b016cbb3d31fbf57a8b8a87428e6158d0c74", + "sha256:a99324196732f53093a84c4369c996713eb8c89d360a496b599fb1a9c47fc3eb", + "sha256:b80486a6c77252ea3a3e9b1e360bc9cf28eaac41263d173c032581ad2f20fe45", + "sha256:c29a5e5cc7a3f05926aff34e097e84f8589cd790ce0ed41b67aed6857b26aafd", + "sha256:cbac4bc38d117f2a49aeedec4407d23e8866ea4ac27ff2cf7fb3e5b570df19e0", + "sha256:f39edd8c4ecaa4556e989147ebf219227e2cd2e8a43c7e7fcb1f1c18c5fd6a3d", + "sha256:fe0644d9ab041506b62782e92b06b8c68cca799e1a9636ec398675459e031405" + ], + "version": "==0.2.8" + }, + "pycparser": { + "hashes": [ + "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0", + "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==2.20" + }, + "pyjwt": { + "hashes": [ + "sha256:5c6eca3c2940464d106b99ba83b00c6add741c9becaec087fb7ccdefea71350e", + "sha256:8d59a976fb773f3e6a39c85636357c4f0e242707394cadadd9814f5cbaa20e96" + ], + "version": "==1.7.1" + }, + "pyotp": { + "hashes": [ + "sha256:038a3f70b34eaad3f72459e8b411662ef8dfcdd95f7d9203fa489e987a75584b", + "sha256:ef07c393660529261e66902e788b32e46260d2c29eb740978df778260a1c2b4c" + ], + "version": "==2.4.1" + }, + "pyparsing": { + "hashes": [ + "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", + "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" + ], + "index": "pypi", + "version": "==2.4.7" + }, + "python-dateutil": { + "hashes": [ + "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c", + "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==2.8.1" + }, + "python-http-client": { + "hashes": [ + "sha256:0a5855902cede46775912d418a23f05fe6f5d60371df1084bef8c219218ce8d9", + "sha256:7e430f4b9dd2b621b0051f6a362f103447ea8e267594c602a5c502a0c694ee38", + "sha256:84267d8dcb7bcdf4c5cef321a533cc584c5b52159d4a4d3d4139bfed347b8006" + ], + "index": "pypi", + "version": "==3.1.0" + }, + "python3-openid": { + "hashes": [ + "sha256:33fbf6928f401e0b790151ed2b5290b02545e8775f982485205a066f874aaeaf", + "sha256:6626f771e0417486701e0b4daff762e7212e820ca5b29fcc0d05f6f8736dfa6b" + ], + "markers": "python_version >= '3.0'", + "version": "==3.2.0" + }, + "pytz": { + "hashes": [ + "sha256:3e6b7dd2d1e0a59084bcee14a17af60c5c562cdc16d828e8eba2e683d3a7e268", + "sha256:5c55e189b682d420be27c6995ba6edce0c0a77dd67bfbe2ae6607134d5851ffd" + ], + "version": "==2020.4" + }, + "requests": { + "hashes": [ + "sha256:7f1a0b932f4a60a1a65caa4263921bb7d9ee911957e0ae4a23a6dd08185ad5f8", + "sha256:e786fa28d8c9154e6a4de5d46a1d921b8749f8b74e28bde23768e5e16eece998" + ], + "index": "pypi", + "version": "==2.25.0" + }, + "requests-oauthlib": { + "hashes": [ + "sha256:7f71572defaecd16372f9006f33c2ec8c077c3cfa6f5911a9a90202beb513f3d", + "sha256:b4261601a71fd721a8bd6d7aa1cc1d6a8a93b4a9f5e96626f8e4d91e8beeaa6a", + "sha256:fa6c47b933f01060936d87ae9327fead68768b69c6c9ea2109c48be30f2d4dbc" + ], + "version": "==1.3.0" + }, + "rsa": { + "hashes": [ + "sha256:109ea5a66744dd859bf16fe904b8d8b627adafb9408753161e766a92e7d681fa", + "sha256:6166864e23d6b5195a5cfed6cd9fed0fe774e226d8f854fcb23b7bbef0350233" + ], + "markers": "python_version >= '3.5'", + "version": "==4.6" + }, + "ruamel.yaml": { + "hashes": [ + "sha256:012b9470a0ea06e4e44e99e7920277edf6b46eee0232a04487ea73a7386340a5", + "sha256:076cc0bc34f1966d920a49f18b52b6ad559fbe656a0748e3535cf7b3f29ebf9e" + ], + "version": "==0.16.12" + }, + "ruamel.yaml.clib": { + "hashes": [ + "sha256:058a1cc3df2a8aecc12f983a48bda99315cebf55a3b3a5463e37bb599b05727b", + "sha256:2602e91bd5c1b874d6f93d3086f9830f3e907c543c7672cf293a97c3fabdcd91", + "sha256:28116f204103cb3a108dfd37668f20abe6e3cafd0d3fd40dba126c732457b3cc", + "sha256:2d24bd98af676f4990c4d715bcdc2a60b19c56a3fb3a763164d2d8ca0e806ba7", + "sha256:30dca9bbcbb1cc858717438218d11eafb78666759e5094dd767468c0d577a7e7", + "sha256:44c7b0498c39f27795224438f1a6be6c5352f82cb887bc33d962c3a3acc00df6", + "sha256:464e66a04e740d754170be5e740657a3b3b6d2bcc567f0c3437879a6e6087ff6", + "sha256:4df5019e7783d14b79217ad9c56edf1ba7485d614ad5a385d1b3c768635c81c0", + "sha256:4e52c96ca66de04be42ea2278012a2342d89f5e82b4512fb6fb7134e377e2e62", + "sha256:5254af7d8bdf4d5484c089f929cb7f5bafa59b4f01d4f48adda4be41e6d29f99", + "sha256:52ae5739e4b5d6317b52f5b040b1b6639e8af68a5b8fd606a8b08658fbd0cab5", + "sha256:53b9dd1abd70e257a6e32f934ebc482dac5edb8c93e23deb663eac724c30b026", + "sha256:73b3d43e04cc4b228fa6fa5d796409ece6fcb53a6c270eb2048109cbcbc3b9c2", + "sha256:74161d827407f4db9072011adcfb825b5258a5ccb3d2cd518dd6c9edea9e30f1", + "sha256:839dd72545ef7ba78fd2aa1a5dd07b33696adf3e68fae7f31327161c1093001b", + "sha256:8e8fd0a22c9d92af3a34f91e8a2594eeb35cba90ab643c5e0e643567dc8be43e", + "sha256:a873e4d4954f865dcb60bdc4914af7eaae48fb56b60ed6daa1d6251c72f5337c", + "sha256:ab845f1f51f7eb750a78937be9f79baea4a42c7960f5a94dde34e69f3cce1988", + "sha256:b1e981fe1aff1fd11627f531524826a4dcc1f26c726235a52fcb62ded27d150f", + "sha256:b4b0d31f2052b3f9f9b5327024dc629a253a83d8649d4734ca7f35b60ec3e9e5", + "sha256:c6ac7e45367b1317e56f1461719c853fd6825226f45b835df7436bb04031fd8a", + "sha256:daf21aa33ee9b351f66deed30a3d450ab55c14242cfdfcd377798e2c0d25c9f1", + "sha256:e9f7d1d8c26a6a12c23421061f9022bb62704e38211fe375c645485f38df34a2", + "sha256:f6061a31880c1ed6b6ce341215336e2f3d0c1deccd84957b6fa8ca474b41e89f" + ], + "markers": "python_version < '3.9' and platform_python_implementation == 'CPython'", + "version": "==0.2.2" + }, + "rx": { + "hashes": [ + "sha256:0e0f2715a3452e95dcb5d6ea28ffe5742e832592bbcc67a48f394ef8ba871e6f", + "sha256:562851cfdb27fd5a218443cdbd682029684144dbafeb5dce34f6a709511282de" + ], + "markers": "python_full_version >= '3.6.0'", + "version": "==3.1.1" + }, + "s3transfer": { + "hashes": [ + "sha256:2482b4259524933a022d59da830f51bd746db62f047d6eb213f2f8855dcb8a13", + "sha256:921a37e2aefc64145e7b73d50c71bb4f26f46e4c9f414dc648c6245ff92cf7db" + ], + "version": "==0.3.3" + }, + "sendgrid": { + "hashes": [ + "sha256:9fba62068dd13922004b6a1676e21c6435709aaf7c2b978cdf1206e3d2196c60", + "sha256:d1af52f8cbb900bf79e28aa08102a503f5e26489c7b9f1d9750758a4b27c84d1" + ], + "version": "==3.6.5" + }, + "sendgrid-django": { + "hashes": [ + "sha256:fef60ba37d588e5e1c4dcbb3b8b7f8c16920e3e89432de357f6abd6157c4f5f4" + ], + "index": "pypi", + "version": "==4.2.0" + }, + "shortuuid": { + "hashes": [ + "sha256:3c11d2007b915c43bee3e10625f068d8a349e04f0d81f08f5fa08507427ebf1f", + "sha256:492c7402ff91beb1342a5898bd61ea953985bf24a41cd9f247409aa2e03c8f77" + ], + "index": "pypi", + "version": "==1.0.1" + }, + "six": { + "hashes": [ + "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", + "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" + ], + "index": "pypi", + "version": "==1.15.0" + }, + "smsapi-client": { + "hashes": [ + "sha256:24dbc95271643268fec3995ee2630165ac2abaa72795bef4945a6ed8f56f81da", + "sha256:81677a9fde0701557f0b8a8009912a817945ebd7e7e8cb8643dc426b7ec90974" + ], + "version": "==2.4.3" + }, + "social-auth-app-django": { + "hashes": [ + "sha256:2c69e57df0b30c9c1823519c5f1992cbe4f3f98fdc7d95c840e091a752708840", + "sha256:567ad0e028311541d7dfed51d3bf2c60440a6fd236d5d4d06c5a618b3d6c57c5", + "sha256:df5212370bd250108987c4748419a1a1d0cec750878856c2644c36aaa0fd3e58" + ], + "version": "==4.0.0" + }, + "social-auth-core": { + "hashes": [ + "sha256:21c0639c56befd33ec162c2210d583bb1de8e1136d53b21bafb96afaf2f86c91", + "sha256:2f6ce1af8ec2b2cc37b86d647f7d4e4292f091ee556941db34b1e0e2dee77fc0", + "sha256:4a3cdf69c449b235cdabd54a1be7ba3722611297e69fded52e3584b1a990af25" + ], + "version": "==3.3.3" + }, + "sqlparse": { + "hashes": [ + "sha256:017cde379adbd6a1f15a61873f43e8274179378e95ef3fede90b5aa64d304ed0", + "sha256:0f91fd2e829c44362cbcfab3e9ae12e22badaa8a29ad5ff599f9ec109f0454e8" + ], + "markers": "python_version >= '3.5'", + "version": "==0.4.1" + }, + "twilio": { + "hashes": [ + "sha256:effb4d6e9e9a9069065fbe21dea844597376ae6d6333626f14b05ba6b35bbb22" + ], + "version": "==6.47.0" + }, + "uritemplate": { + "hashes": [ + "sha256:07620c3f3f8eed1f12600845892b0e036a2420acf513c53f7de0abd911a5894f", + "sha256:5af8ad10cec94f215e3f48112de2022e1d5a37ed427fbd88652fa908f2ab7cae" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==3.0.1" + }, + "urllib3": { + "hashes": [ + "sha256:19188f96923873c92ccb987120ec4acaa12f0461fa9ce5d3d0772bc965a39e08", + "sha256:d8ff90d979214d7b4f8ce956e80f4028fc6860e4431f731ea4a8c08f23f99473" + ], + "markers": "python_version != '3.4'", + "version": "==1.26.2" + }, + "whitenoise": { + "hashes": [ + "sha256:15f43b2e701821b95c9016cf469d29e2a546cb1c7dead584ba82c36f843995cf", + "sha256:9d81515f2b5b27051910996e1e860b1332e354d9e7bcf30c98f21dcb6713e0dd" + ], + "index": "pypi", + "version": "==3.3.1" + }, + "yubico-client": { + "hashes": [ + "sha256:59d818661f638e3f041fae44ba2c0569e4eb2a17865fa7cc9ad6577185c4d185", + "sha256:e3b86cd2a123105edfacad40551c7b26e9c1193d81ffe168ee704ebfd3d11162" + ], + "version": "==1.13.0" + } + }, + "develop": { + "appdirs": { + "hashes": [ + "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41", + "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128" + ], + "index": "pypi", + "version": "==1.4.4" + }, + "astroid": { + "hashes": [ + "sha256:2f4078c2a41bf377eea06d71c9d2ba4eb8f6b1af2135bec27bbbb7d8f12bb703", + "sha256:bc58d83eb610252fd8de6363e39d4f1d0619c894b0ed24603b881c02e64c7386" + ], + "markers": "python_version >= '3.5'", + "version": "==2.4.2" + }, + "attrs": { + "hashes": [ + "sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6", + "sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==20.3.0" + }, + "autopep8": { + "hashes": [ + "sha256:d21d3901cb0da6ebd1e83fc9b0dfbde8b46afc2ede4fe32fbda0c7c6118ca094" + ], + "index": "pypi", + "version": "==1.5.4" + }, + "black": { + "hashes": [ + "sha256:1c02557aa099101b9d21496f8a914e9ed2222ef70336404eeeac8edba836fbea" + ], + "index": "pypi", + "version": "==20.8b1" + }, + "click": { + "hashes": [ + "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a", + "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", + "version": "==7.1.2" + }, + "django-test-migrations": { + "hashes": [ + "sha256:d120d0287e1dd82ed62fe083747a1e99c0398d56beda52594e8391b94a41bef5", + "sha256:e5747e2ad0b7e4d3b8d9ccd40d414b0f186316d3757af022b4bbdec700897521" + ], + "index": "pypi", + "version": "==1.0.0" + }, + "iniconfig": { + "hashes": [ + "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3", + "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32" + ], + "version": "==1.1.1" + }, + "isort": { + "hashes": [ + "sha256:dcab1d98b469a12a1a624ead220584391648790275560e1a43e54c5dceae65e7", + "sha256:dcaeec1b5f0eca77faea2a35ab790b4f3680ff75590bfcb7145986905aab2f58" + ], + "markers": "python_version >= '3.6' and python_version < '4.0'", + "version": "==5.6.4" + }, + "lazy-object-proxy": { + "hashes": [ + "sha256:0c4b206227a8097f05c4dbdd323c50edf81f15db3b8dc064d08c62d37e1a504d", + "sha256:194d092e6f246b906e8f70884e620e459fc54db3259e60cf69a4d66c3fda3449", + "sha256:1be7e4c9f96948003609aa6c974ae59830a6baecc5376c25c92d7d697e684c08", + "sha256:4677f594e474c91da97f489fea5b7daa17b5517190899cf213697e48d3902f5a", + "sha256:48dab84ebd4831077b150572aec802f303117c8cc5c871e182447281ebf3ac50", + "sha256:5541cada25cd173702dbd99f8e22434105456314462326f06dba3e180f203dfd", + "sha256:59f79fef100b09564bc2df42ea2d8d21a64fdcda64979c0fa3db7bdaabaf6239", + "sha256:8d859b89baf8ef7f8bc6b00aa20316483d67f0b1cbf422f5b4dc56701c8f2ffb", + "sha256:9254f4358b9b541e3441b007a0ea0764b9d056afdeafc1a5569eee1cc6c1b9ea", + "sha256:9651375199045a358eb6741df3e02a651e0330be090b3bc79f6d0de31a80ec3e", + "sha256:97bb5884f6f1cdce0099f86b907aa41c970c3c672ac8b9c8352789e103cf3156", + "sha256:9b15f3f4c0f35727d3a0fba4b770b3c4ebbb1fa907dbcc046a1d2799f3edd142", + "sha256:a2238e9d1bb71a56cd710611a1614d1194dc10a175c1e08d75e1a7bcc250d442", + "sha256:a6ae12d08c0bf9909ce12385803a543bfe99b95fe01e752536a60af2b7797c62", + "sha256:ca0a928a3ddbc5725be2dd1cf895ec0a254798915fb3a36af0964a0a4149e3db", + "sha256:cb2c7c57005a6804ab66f106ceb8482da55f5314b7fcb06551db1edae4ad1531", + "sha256:d74bb8693bf9cf75ac3b47a54d716bbb1a92648d5f781fc799347cfc95952383", + "sha256:d945239a5639b3ff35b70a88c5f2f491913eb94871780ebfabb2568bd58afc5a", + "sha256:eba7011090323c1dadf18b3b689845fd96a61ba0a1dfbd7f24b921398affc357", + "sha256:efa1909120ce98bbb3777e8b6f92237f5d5c8ea6758efea36a473e1d38f7d3e4", + "sha256:f3900e8a5de27447acbf900b4750b0ddfd7ec1ea7fbaf11dfa911141bc522af0" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==1.4.3" + }, + "mccabe": { + "hashes": [ + "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", + "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f" + ], + "version": "==0.6.1" + }, + "mypy-extensions": { + "hashes": [ + "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d", + "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8" + ], + "version": "==0.4.3" + }, + "packaging": { + "hashes": [ + "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8", + "sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181" + ], + "index": "pypi", + "version": "==20.4" + }, + "pathspec": { + "hashes": [ + "sha256:86379d6b86d75816baba717e64b1a3a3469deb93bb76d613c9ce79edc5cb68fd", + "sha256:aa0cb481c4041bf52ffa7b0d8fa6cd3e88a2ca4879c533c9153882ee2556790d" + ], + "version": "==0.8.1" + }, + "pep8": { + "hashes": [ + "sha256:b22cfae5db09833bb9bd7c8463b53e1a9c9b39f12e304a8d0bba729c501827ee", + "sha256:fe249b52e20498e59e0b5c5256aa52ee99fc295b26ec9eaa85776ffdb9fe6374" + ], + "index": "pypi", + "version": "==1.7.1" + }, + "pluggy": { + "hashes": [ + "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0", + "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==0.13.1" + }, + "py": { + "hashes": [ + "sha256:366389d1db726cd2fcfc79732e75410e5fe4d31db13692115529d34069a043c2", + "sha256:9ca6883ce56b4e8da7e79ac18787889fa5206c79dcc67fb065376cd2fe03f342" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==1.9.0" + }, + "pycodestyle": { + "hashes": [ + "sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367", + "sha256:c58a7d2815e0e8d7972bf1803331fb0152f867bd89adf8a01dfd55085434192e" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==2.6.0" + }, + "pylint": { + "hashes": [ + "sha256:bb4a908c9dadbc3aac18860550e870f58e1a02c9f2c204fdf5693d73be061210", + "sha256:bfe68f020f8a0fece830a22dd4d5dddb4ecc6137db04face4c3420a46a52239f" + ], + "index": "pypi", + "version": "==2.6.0" + }, + "pyparsing": { + "hashes": [ + "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", + "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" + ], + "index": "pypi", + "version": "==2.4.7" + }, + "pytest": { + "hashes": [ + "sha256:4288fed0d9153d9646bfcdf0c0428197dba1ecb27a33bb6e031d002fa88653fe", + "sha256:c0a7e94a8cdbc5422a51ccdad8e6f1024795939cc89159a0ae7f0b316ad3823e" + ], + "index": "pypi", + "version": "==6.1.2" + }, + "pytest-django": { + "hashes": [ + "sha256:10e384e6b8912ded92db64c58be8139d9ae23fb8361e5fc139d8e4f8fc601bc2", + "sha256:26f02c16d36fd4c8672390deebe3413678d89f30720c16efb8b2a6bf63b9041f" + ], + "index": "pypi", + "version": "==4.1.0" + }, + "regex": { + "hashes": [ + "sha256:02951b7dacb123d8ea6da44fe45ddd084aa6777d4b2454fa0da61d569c6fa538", + "sha256:0d08e71e70c0237883d0bef12cad5145b84c3705e9c6a588b2a9c7080e5af2a4", + "sha256:1862a9d9194fae76a7aaf0150d5f2a8ec1da89e8b55890b1786b8f88a0f619dc", + "sha256:1ab79fcb02b930de09c76d024d279686ec5d532eb814fd0ed1e0051eb8bd2daa", + "sha256:1fa7ee9c2a0e30405e21031d07d7ba8617bc590d391adfc2b7f1e8b99f46f444", + "sha256:262c6825b309e6485ec2493ffc7e62a13cf13fb2a8b6d212f72bd53ad34118f1", + "sha256:2a11a3e90bd9901d70a5b31d7dd85114755a581a5da3fc996abfefa48aee78af", + "sha256:2c99e97d388cd0a8d30f7c514d67887d8021541b875baf09791a3baad48bb4f8", + "sha256:3128e30d83f2e70b0bed9b2a34e92707d0877e460b402faca908c6667092ada9", + "sha256:38c8fd190db64f513fe4e1baa59fed086ae71fa45083b6936b52d34df8f86a88", + "sha256:3bddc701bdd1efa0d5264d2649588cbfda549b2899dc8d50417e47a82e1387ba", + "sha256:4902e6aa086cbb224241adbc2f06235927d5cdacffb2425c73e6570e8d862364", + "sha256:49cae022fa13f09be91b2c880e58e14b6da5d10639ed45ca69b85faf039f7a4e", + "sha256:56e01daca75eae420bce184edd8bb341c8eebb19dd3bce7266332258f9fb9dd7", + "sha256:5862975b45d451b6db51c2e654990c1820523a5b07100fc6903e9c86575202a0", + "sha256:6a8ce43923c518c24a2579fda49f093f1397dad5d18346211e46f134fc624e31", + "sha256:6c54ce4b5d61a7129bad5c5dc279e222afd00e721bf92f9ef09e4fae28755683", + "sha256:6e4b08c6f8daca7d8f07c8d24e4331ae7953333dbd09c648ed6ebd24db5a10ee", + "sha256:717881211f46de3ab130b58ec0908267961fadc06e44f974466d1887f865bd5b", + "sha256:749078d1eb89484db5f34b4012092ad14b327944ee7f1c4f74d6279a6e4d1884", + "sha256:7913bd25f4ab274ba37bc97ad0e21c31004224ccb02765ad984eef43e04acc6c", + "sha256:7a25fcbeae08f96a754b45bdc050e1fb94b95cab046bf56b016c25e9ab127b3e", + "sha256:83d6b356e116ca119db8e7c6fc2983289d87b27b3fac238cfe5dca529d884562", + "sha256:8b882a78c320478b12ff024e81dc7d43c1462aa4a3341c754ee65d857a521f85", + "sha256:8f6a2229e8ad946e36815f2a03386bb8353d4bde368fdf8ca5f0cb97264d3b5c", + "sha256:9801c4c1d9ae6a70aeb2128e5b4b68c45d4f0af0d1535500884d644fa9b768c6", + "sha256:a15f64ae3a027b64496a71ab1f722355e570c3fac5ba2801cafce846bf5af01d", + "sha256:a3d748383762e56337c39ab35c6ed4deb88df5326f97a38946ddd19028ecce6b", + "sha256:a63f1a07932c9686d2d416fb295ec2c01ab246e89b4d58e5fa468089cab44b70", + "sha256:b2b1a5ddae3677d89b686e5c625fc5547c6e492bd755b520de5332773a8af06b", + "sha256:b2f4007bff007c96a173e24dcda236e5e83bde4358a557f9ccf5e014439eae4b", + "sha256:baf378ba6151f6e272824b86a774326f692bc2ef4cc5ce8d5bc76e38c813a55f", + "sha256:bafb01b4688833e099d79e7efd23f99172f501a15c44f21ea2118681473fdba0", + "sha256:bba349276b126947b014e50ab3316c027cac1495992f10e5682dc677b3dfa0c5", + "sha256:c084582d4215593f2f1d28b65d2a2f3aceff8342aa85afd7be23a9cad74a0de5", + "sha256:d1ebb090a426db66dd80df8ca85adc4abfcbad8a7c2e9a5ec7513ede522e0a8f", + "sha256:d2d8ce12b7c12c87e41123997ebaf1a5767a5be3ec545f64675388970f415e2e", + "sha256:e32f5f3d1b1c663af7f9c4c1e72e6ffe9a78c03a31e149259f531e0fed826512", + "sha256:e3faaf10a0d1e8e23a9b51d1900b72e1635c2d5b0e1bea1c18022486a8e2e52d", + "sha256:f7d29a6fc4760300f86ae329e3b6ca28ea9c20823df123a2ea8693e967b29917", + "sha256:f8f295db00ef5f8bae530fc39af0b40486ca6068733fb860b42115052206466f" + ], + "version": "==2020.11.13" + }, + "six": { + "hashes": [ + "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", + "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" + ], + "index": "pypi", + "version": "==1.15.0" + }, + "toml": { + "hashes": [ + "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", + "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f" + ], + "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==0.10.2" + }, + "typed-ast": { + "hashes": [ + "sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355", + "sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919", + "sha256:0d8110d78a5736e16e26213114a38ca35cb15b6515d535413b090bd50951556d", + "sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa", + "sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652", + "sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75", + "sha256:3742b32cf1c6ef124d57f95be609c473d7ec4c14d0090e5a5e05a15269fb4d0c", + "sha256:4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01", + "sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d", + "sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1", + "sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907", + "sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c", + "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3", + "sha256:7e4c9d7658aaa1fc80018593abdf8598bf91325af6af5cce4ce7c73bc45ea53d", + "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b", + "sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614", + "sha256:92c325624e304ebf0e025d1224b77dd4e6393f18aab8d829b5b7e04afe9b7a2c", + "sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb", + "sha256:b52ccf7cfe4ce2a1064b18594381bccf4179c2ecf7f513134ec2f993dd4ab395", + "sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b", + "sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41", + "sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6", + "sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34", + "sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe", + "sha256:d648b8e3bf2fe648745c8ffcee3db3ff903d0817a01a12dd6a6ea7a8f4889072", + "sha256:f208eb7aff048f6bea9586e61af041ddf7f9ade7caed625742af423f6bae3298", + "sha256:fac11badff8313e23717f3dada86a15389d0708275bddf766cca67a84ead3e91", + "sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4", + "sha256:fcf135e17cc74dbfbc05894ebca928ffeb23d9790b3167a674921db19082401f", + "sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7" + ], + "version": "==1.4.1" + }, + "typing-extensions": { + "hashes": [ + "sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918", + "sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c", + "sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f" + ], + "version": "==3.7.4.3" + }, + "wrapt": { + "hashes": [ + "sha256:b62ffa81fb85f4332a4f609cab4ac40709470da05643a082ec1eb88e6d9b97d7" + ], + "version": "==1.12.1" + } + } +} From 1440e3e55ee0cd7378119533af982994a06f641b Mon Sep 17 00:00:00 2001 From: Ben Rometsch Date: Thu, 19 Nov 2020 09:46:57 +0000 Subject: [PATCH 27/47] Fixed requirements Fixed test to require chargebee --- Pipfile.lock | 1141 -------------------------------------------------- 1 file changed, 1141 deletions(-) delete mode 100644 Pipfile.lock diff --git a/Pipfile.lock b/Pipfile.lock deleted file mode 100644 index b1fad9b01170..000000000000 --- a/Pipfile.lock +++ /dev/null @@ -1,1141 +0,0 @@ -{ - "_meta": { - "hash": { - "sha256": "5dc316c5f1c6d96ef50a52256ed643f5e3d7f449fb8117ca67bf02576e707b75" - }, - "pipfile-spec": 6, - "requires": {}, - "sources": [ - { - "name": "pypi", - "url": "https://pypi.org/simple", - "verify_ssl": true - } - ] - }, - "default": { - "appdirs": { - "hashes": [ - "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41", - "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128" - ], - "index": "pypi", - "version": "==1.4.4" - }, - "asgiref": { - "hashes": [ - "sha256:5ee950735509d04eb673bd7f7120f8fa1c9e2df495394992c73234d526907e17", - "sha256:7162a3cb30ab0609f1a4c95938fd73e8604f63bdba516a7f7d64b83ff09478f0" - ], - "markers": "python_version >= '3.5'", - "version": "==3.3.1" - }, - "boto3": { - "hashes": [ - "sha256:0a4c72ef591fd4291efb9767a6a94f625afccc1e0fc7843968cd39eb6289c3e4", - "sha256:6c5d952f97e13997b1c7463038d96469355595cd37f87b43451759cc03756322" - ], - "version": "==1.16.20" - }, - "botocore": { - "hashes": [ - "sha256:00a69a507e8b817d0703c612131e6cb3a3260579d0353c56d005bd9effd92ec0", - "sha256:4576ca751264c65420daae07e244beafdfea493b4fbb815d37215c0736dc4633" - ], - "version": "==1.19.20" - }, - "cachetools": { - "hashes": [ - "sha256:513d4ff98dd27f85743a8dc0e92f55ddb1b49e060c2d5961512855cda2c01a98", - "sha256:bbaa39c3dede00175df2dc2b03d0cf18dd2d32a7de7beb68072d13043c9edb20" - ], - "markers": "python_version ~= '3.5'", - "version": "==4.1.1" - }, - "certifi": { - "hashes": [ - "sha256:1f422849db327d534e3d0c5f02a263458c3955ec0aae4ff09b95f195c59f4edd", - "sha256:f05def092c44fbf25834a51509ef6e631dc19765ab8a57b4e7ab85531f0a9cf4" - ], - "version": "==2020.11.8" - }, - "cffi": { - "hashes": [ - "sha256:005f2bfe11b6745d726dbb07ace4d53f057de66e336ff92d61b8c7e9c8f4777d", - "sha256:09e96138280241bd355cd585148dec04dbbedb4f46128f340d696eaafc82dd7b", - "sha256:0b1ad452cc824665ddc682400b62c9e4f5b64736a2ba99110712fdee5f2505c4", - "sha256:0ef488305fdce2580c8b2708f22d7785ae222d9825d3094ab073e22e93dfe51f", - "sha256:15f351bed09897fbda218e4db5a3d5c06328862f6198d4fb385f3e14e19decb3", - "sha256:22399ff4870fb4c7ef19fff6eeb20a8bbf15571913c181c78cb361024d574579", - "sha256:23e5d2040367322824605bc29ae8ee9175200b92cb5483ac7d466927a9b3d537", - "sha256:2791f68edc5749024b4722500e86303a10d342527e1e3bcac47f35fbd25b764e", - "sha256:2f9674623ca39c9ebe38afa3da402e9326c245f0f5ceff0623dccdac15023e05", - "sha256:3363e77a6176afb8823b6e06db78c46dbc4c7813b00a41300a4873b6ba63b171", - "sha256:33c6cdc071ba5cd6d96769c8969a0531be2d08c2628a0143a10a7dcffa9719ca", - "sha256:3b8eaf915ddc0709779889c472e553f0d3e8b7bdf62dab764c8921b09bf94522", - "sha256:3cb3e1b9ec43256c4e0f8d2837267a70b0e1ca8c4f456685508ae6106b1f504c", - "sha256:3eeeb0405fd145e714f7633a5173318bd88d8bbfc3dd0a5751f8c4f70ae629bc", - "sha256:44f60519595eaca110f248e5017363d751b12782a6f2bd6a7041cba275215f5d", - "sha256:4d7c26bfc1ea9f92084a1d75e11999e97b62d63128bcc90c3624d07813c52808", - "sha256:529c4ed2e10437c205f38f3691a68be66c39197d01062618c55f74294a4a4828", - "sha256:6642f15ad963b5092d65aed022d033c77763515fdc07095208f15d3563003869", - "sha256:85ba797e1de5b48aa5a8427b6ba62cf69607c18c5d4eb747604b7302f1ec382d", - "sha256:8f0f1e499e4000c4c347a124fa6a27d37608ced4fe9f7d45070563b7c4c370c9", - "sha256:a624fae282e81ad2e4871bdb767e2c914d0539708c0f078b5b355258293c98b0", - "sha256:b0358e6fefc74a16f745afa366acc89f979040e0cbc4eec55ab26ad1f6a9bfbc", - "sha256:bbd2f4dfee1079f76943767fce837ade3087b578aeb9f69aec7857d5bf25db15", - "sha256:bf39a9e19ce7298f1bd6a9758fa99707e9e5b1ebe5e90f2c3913a47bc548747c", - "sha256:c11579638288e53fc94ad60022ff1b67865363e730ee41ad5e6f0a17188b327a", - "sha256:c150eaa3dadbb2b5339675b88d4573c1be3cb6f2c33a6c83387e10cc0bf05bd3", - "sha256:c53af463f4a40de78c58b8b2710ade243c81cbca641e34debf3396a9640d6ec1", - "sha256:cb763ceceae04803adcc4e2d80d611ef201c73da32d8f2722e9d0ab0c7f10768", - "sha256:cc75f58cdaf043fe6a7a6c04b3b5a0e694c6a9e24050967747251fb80d7bce0d", - "sha256:d80998ed59176e8cba74028762fbd9b9153b9afc71ea118e63bbf5d4d0f9552b", - "sha256:de31b5164d44ef4943db155b3e8e17929707cac1e5bd2f363e67a56e3af4af6e", - "sha256:e66399cf0fc07de4dce4f588fc25bfe84a6d1285cc544e67987d22663393926d", - "sha256:f0620511387790860b249b9241c2f13c3a80e21a73e0b861a2df24e9d6f56730", - "sha256:f4eae045e6ab2bb54ca279733fe4eb85f1effda392666308250714e01907f394", - "sha256:f92cdecb618e5fa4658aeb97d5eb3d2f47aa94ac6477c6daf0f306c5a3b9e6b1", - "sha256:f92f789e4f9241cd262ad7a555ca2c648a98178a953af117ef7fad46aa1d5591" - ], - "version": "==1.14.3" - }, - "chardet": { - "hashes": [ - "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", - "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" - ], - "version": "==3.0.4" - }, - "chargebee": { - "hashes": [ - "sha256:95b1f7ff91737103f6115edb5f2329f525a4f181770597c6b9d2683628f9a92d" - ], - "index": "pypi", - "version": "==2.7.8" - }, - "coreapi": { - "hashes": [ - "sha256:46145fcc1f7017c076a2ef684969b641d18a2991051fddec9458ad3f78ffc1cb", - "sha256:bf39d118d6d3e171f10df9ede5666f63ad80bba9a29a8ec17726a66cf52ee6f3" - ], - "index": "pypi", - "version": "==2.3.3" - }, - "coreschema": { - "hashes": [ - "sha256:5e6ef7bf38c1525d5e55a895934ab4273548629f16aed5c0a6caa74ebf45551f", - "sha256:9503506007d482ab0867ba14724b93c18a33b22b6d19fb419ef2d239dd4a1607" - ], - "version": "==0.0.4" - }, - "cryptography": { - "hashes": [ - "sha256:07ca431b788249af92764e3be9a488aa1d39a0bc3be313d826bbec690417e538", - "sha256:13b88a0bd044b4eae1ef40e265d006e34dbcde0c2f1e15eb9896501b2d8f6c6f", - "sha256:32434673d8505b42c0de4de86da8c1620651abd24afe91ae0335597683ed1b77", - "sha256:3cd75a683b15576cfc822c7c5742b3276e50b21a06672dc3a800a2d5da4ecd1b", - "sha256:4e7268a0ca14536fecfdf2b00297d4e407da904718658c1ff1961c713f90fd33", - "sha256:545a8550782dda68f8cdc75a6e3bf252017aa8f75f19f5a9ca940772fc0cb56e", - "sha256:55d0b896631412b6f0c7de56e12eb3e261ac347fbaa5d5e705291a9016e5f8cb", - "sha256:5849d59358547bf789ee7e0d7a9036b2d29e9a4ddf1ce5e06bb45634f995c53e", - "sha256:6dc59630ecce8c1f558277ceb212c751d6730bd12c80ea96b4ac65637c4f55e7", - "sha256:7117319b44ed1842c617d0a452383a5a052ec6aa726dfbaffa8b94c910444297", - "sha256:75e8e6684cf0034f6bf2a97095cb95f81537b12b36a8fedf06e73050bb171c2d", - "sha256:7b8d9d8d3a9bd240f453342981f765346c87ade811519f98664519696f8e6ab7", - "sha256:a035a10686532b0587d58a606004aa20ad895c60c4d029afa245802347fab57b", - "sha256:a4e27ed0b2504195f855b52052eadcc9795c59909c9d84314c5408687f933fc7", - "sha256:a733671100cd26d816eed39507e585c156e4498293a907029969234e5e634bc4", - "sha256:a75f306a16d9f9afebfbedc41c8c2351d8e61e818ba6b4c40815e2b5740bb6b8", - "sha256:bd717aa029217b8ef94a7d21632a3bb5a4e7218a4513d2521c2a2fd63011e98b", - "sha256:d25cecbac20713a7c3bc544372d42d8eafa89799f492a43b79e1dfd650484851", - "sha256:d26a2557d8f9122f9bf445fc7034242f4375bd4e95ecda007667540270965b13", - "sha256:d3545829ab42a66b84a9aaabf216a4dce7f16dbc76eb69be5c302ed6b8f4a29b", - "sha256:d3d5e10be0cf2a12214ddee45c6bd203dab435e3d83b4560c03066eda600bfe3", - "sha256:efe15aca4f64f3a7ea0c09c87826490e50ed166ce67368a68f315ea0807a20df" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", - "version": "==3.2.1" - }, - "defusedxml": { - "hashes": [ - "sha256:6687150770438374ab581bb7a1b327a847dd9c5749e396102de3fad4e8a3ef93", - "sha256:f684034d135af4c6cbb949b8a4d2ed61634515257a67299e5f940fbaa34377f5" - ], - "markers": "python_version >= '3.0'", - "version": "==0.6.0" - }, - "dj-database-url": { - "hashes": [ - "sha256:4aeaeb1f573c74835b0686a2b46b85990571159ffc21aa57ecd4d1e1cb334163", - "sha256:851785365761ebe4994a921b433062309eb882fedd318e1b0fcecc607ed02da9" - ], - "index": "pypi", - "version": "==0.5.0" - }, - "django": { - "hashes": [ - "sha256:558cb27930defd9a6042133258caf797b2d1dee233959f537e3dc475cb49bd7c", - "sha256:cf5370a4d7765a9dd6d42a7b96b53c74f9446cd38209211304b210fe0404b861" - ], - "index": "pypi", - "version": "==2.2.17" - }, - "django-admin-sso": { - "hashes": [ - "sha256:1f11298f9a0fe7c34acfae057ae8e19637046fef0cd4b5b3fa0fcebb1e5526a9", - "sha256:32548dc797296642d4f7b51d37a48fd2fbfd938e2db01f738c4ef4acc16d1d5e" - ], - "index": "pypi", - "version": "==3.0.0" - }, - "django-axes": { - "hashes": [ - "sha256:25827cd722b006845e9abd5ecbfba92ebad3935a29258d457158212d4e1f0ded", - "sha256:320ddc577387b1b7926468b9313fd8048f86b70e35a02faa858cbac003900374" - ], - "index": "pypi", - "version": "==5.9.0" - }, - "django-cors-headers": { - "hashes": [ - "sha256:9322255c296d5f75089571f29e520c83ff9693df17aa3cf9f6a4bea7c6740169", - "sha256:db82b2840f667d47872ae3e4a4e0a0d72fbecb42779b8aa233fa8bb965f7836a" - ], - "index": "pypi", - "version": "==3.5.0" - }, - "django-debug-toolbar": { - "hashes": [ - "sha256:a1ce0665f7ef47d27b8df4b5d1058748e1f08500a01421a30d35164f38aaaf4c", - "sha256:c97921a9cd421d392e7860dc4b464db8e06c8628df4dc58fedab012888c293c6" - ], - "index": "pypi", - "version": "==3.1.1" - }, - "django-environ": { - "hashes": [ - "sha256:6c9d87660142608f63ec7d5ce5564c49b603ea8ff25da595fd6098f6dc82afde", - "sha256:c57b3c11ec1f319d9474e3e5a79134f40174b17c7cc024bbb2fad84646b120c4" - ], - "index": "pypi", - "version": "==0.4.5" - }, - "django-health-check": { - "hashes": [ - "sha256:2cb3944e313e435bdf299288e109f398b6c08b610e09cc90d7f5f6a2bcf469fc", - "sha256:8b0835f04ebaeb0d12498a5ef47dd22196237c3987ff28bcce9ed28b5a169d5e" - ], - "index": "pypi", - "version": "==3.16.1" - }, - "django-ipware": { - "hashes": [ - "sha256:c7df8e1410a8e5d6b1fbae58728402ea59950f043c3582e033e866f0f0cf5e94" - ], - "version": "==3.0.2" - }, - "django-ordered-model": { - "hashes": [ - "sha256:29af6624cf3505daaf0df00e2df1d0726dd777b95e08f304d5ad0264092aa934", - "sha256:d867166ed4dd12501139e119cbbc5b4d19798a3e72740aef0af4879ba97102cf" - ], - "index": "pypi", - "version": "==3.4.1" - }, - "django-ses": { - "hashes": [ - "sha256:45f041acb9f2f8df85f3fddd63b04f1d381982bdc7730a3dc1f88e5795758bdd", - "sha256:9c0a3e59e1e2424093820fa7cd519aa35f9ba978c26917a97a30c682449f0df6" - ], - "index": "pypi", - "version": "==1.0.3" - }, - "django-simple-history": { - "hashes": [ - "sha256:3b7bf6bfbcf973afca123c5786c72b917ed4d92d7bf3b6cb70fe2e3850e763a3", - "sha256:e7e830cb7a768dc90d6ba0507f8023f889bcb62fe31a08f18fac102c55eec539" - ], - "index": "pypi", - "version": "==2.12.0" - }, - "django-storages": { - "hashes": [ - "sha256:12de8fb2605b9b57bfaf54b075280d7cbb3b3ee1ca4bc9b9add147af87fe3a2c", - "sha256:652275ab7844538c462b62810276c0244866f345878256a9e0e86f5b1283ae18" - ], - "index": "pypi", - "version": "==1.10.1" - }, - "django-templated-mail": { - "hashes": [ - "sha256:8db807effebb42a532622e2d142dfd453dafcd0d7794c4c3332acb90656315f9", - "sha256:f7127e1e31d7cad4e6c4b4801d25814d4b8782627ead76f4a75b3b7650687556" - ], - "version": "==1.1.1" - }, - "django-trench": { - "hashes": [ - "sha256:63e189a057c45198d178ea79337e690250b484fcd8ff2057c9fd4b3699639853" - ], - "index": "pypi", - "version": "==0.2.3" - }, - "djangorestframework": { - "hashes": [ - "sha256:0209bafcb7b5010fdfec784034f059d512256424de2a0f084cb82b096d6dd6a7" - ], - "index": "pypi", - "version": "==3.12.2" - }, - "djangorestframework-recursive": { - "hashes": [ - "sha256:e4e51b26b7ee3c9f9b838885d638b91293e7c66e85b5955f278a6e10eb34ce7c", - "sha256:f8fc2d677ccb32fe53ec4153a45f66c822d0ce444824cba56edc76ca89b704ae" - ], - "index": "pypi", - "version": "==0.1.2" - }, - "djangorestframework-simplejwt": { - "hashes": [ - "sha256:7adc913ba0d2ed7f46e0b9bf6e86f9bd9248f1c4201722b732b8213e0ea66f9f", - "sha256:bd587700b6ab34a6c6b12d426cce4fa580d57ef1952ad4ba3b79707784619ed3" - ], - "markers": "python_version >= '3.7'", - "version": "==4.6.0" - }, - "djoser": { - "hashes": [ - "sha256:3299073aa5822f9ad02bc872b87e719051c07d36cdc87a05b2afdb2c3bad46d1", - "sha256:9590378d59eb3243572bcb6b0a45268a3e31bedddc15235ca248a18c7bc0ffe6" - ], - "index": "pypi", - "version": "==2.1.0" - }, - "drf-nested-routers": { - "hashes": [ - "sha256:52be428b046078ed21e9137167035cc2eb8daad036900fd2235ce026306e143a", - "sha256:e043fc937f94ac462a92d2d9fc9a7e55710a67164b558442adfe9634fc519c3b" - ], - "index": "pypi", - "version": "==0.92.1" - }, - "drf-yasg2": { - "hashes": [ - "sha256:7037a8041eb5d1073fa504a284fc889685f93d0bfd008a963db1b366db786734", - "sha256:75e661ca5cf15eb44fcfab408c7b864f87c20794f564aa08b3a31817a857f19d" - ], - "index": "pypi", - "version": "==1.19.4" - }, - "future": { - "hashes": [ - "sha256:b1bead90b70cf6ec3f0710ae53a525360fa360d306a86583adc6bf83a4db537d" - ], - "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==0.18.2" - }, - "google-api-core": { - "hashes": [ - "sha256:1bb3c485c38eacded8d685b1759968f6cf47dd9432922d34edb90359eaa391e2", - "sha256:94d8c707d358d8d9e8b0045c42be20efb58433d308bd92cf748511c7825569c8" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.23.0" - }, - "google-api-python-client": { - "hashes": [ - "sha256:1f5cfcb92c8e3bd0a69cb2ff3cefa5ff16ffa1900af795a53f5bed93c8238951", - "sha256:608552e52ea994a014be8bb0489923328a50776190e0858caaf7b186ebad22bf" - ], - "index": "pypi", - "version": "==1.12.6" - }, - "google-auth": { - "hashes": [ - "sha256:5176db85f1e7e837a646cd9cede72c3c404ccf2e3373d9ee14b2db88febad440", - "sha256:b728625ff5dfce8f9e56a499c8a4eb51443a67f20f6d28b67d5774c310ec4b6b" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.23.0" - }, - "google-auth-httplib2": { - "hashes": [ - "sha256:8d092cc60fb16517b12057ec0bba9185a96e3b7169d86ae12eae98e645b7bc39", - "sha256:aeaff501738b289717fac1980db9711d77908a6c227f60e4aa1923410b43e2ee" - ], - "version": "==0.0.4" - }, - "googleapis-common-protos": { - "hashes": [ - "sha256:560716c807117394da12cecb0a54da5a451b5cf9866f1d37e9a5e2329a665351", - "sha256:c8961760f5aad9a711d37b675be103e0cc4e9a39327e0d6d857872f698403e24" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.52.0" - }, - "gunicorn": { - "hashes": [ - "sha256:1904bb2b8a43658807108d59c3f3d56c2b6121a701161de0ddf9ad140073c626", - "sha256:cd4a810dd51bf497552cf3f863b575dabd73d6ad6a91075b65936b151cbf4f9c" - ], - "index": "pypi", - "version": "==20.0.4" - }, - "httplib2": { - "hashes": [ - "sha256:8af66c1c52c7ffe1aa5dc4bcd7c769885254b0756e6e69f953c7f0ab49a70ba3", - "sha256:ca2914b015b6247791c4866782fa6042f495b94401a0f0bd3e1d6e0ba2236782" - ], - "version": "==0.18.1" - }, - "idna": { - "hashes": [ - "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6", - "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==2.10" - }, - "inflection": { - "hashes": [ - "sha256:1a29730d366e996aaacffb2f1f1cb9593dc38e2ddd30c91250c6dde09ea9b417", - "sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2" - ], - "markers": "python_version >= '3.5'", - "version": "==0.5.1" - }, - "influxdb-client": { - "hashes": [ - "sha256:213cece87fbb71411c6d387d65e0cbc3d529bc37c734635e6ee4449443757331", - "sha256:f233da171a2508274d4cebba3ec66d2cc4729229e66419aabeb20220e3b36c82" - ], - "index": "pypi", - "version": "==1.12.0" - }, - "itypes": { - "hashes": [ - "sha256:03da6872ca89d29aef62773672b2d408f490f80db48b23079a4b194c86dd04c6", - "sha256:af886f129dea4a2a1e3d36595a2d139589e4dd287f5cab0b40e799ee81570ff1" - ], - "version": "==1.2.0" - }, - "jinja2": { - "hashes": [ - "sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0", - "sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", - "version": "==2.11.2" - }, - "jmespath": { - "hashes": [ - "sha256:b85d0567b8666149a93172712e68920734333c0ce7e89b78b3e987f71e5ed4f9", - "sha256:cdf6525904cc597730141d61b36f2e4b8ecc257c420fa2f4549bac2c2d0cb72f" - ], - "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==0.10.0" - }, - "markupsafe": { - "hashes": [ - "sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473", - "sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161", - "sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235", - "sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5", - "sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42", - "sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff", - "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b", - "sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1", - "sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e", - "sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183", - "sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66", - "sha256:596510de112c685489095da617b5bcbbac7dd6384aeebeda4df6025d0256a81b", - "sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1", - "sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15", - "sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1", - "sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e", - "sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b", - "sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905", - "sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735", - "sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d", - "sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e", - "sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d", - "sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c", - "sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21", - "sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2", - "sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5", - "sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b", - "sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6", - "sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f", - "sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f", - "sha256:cdb132fc825c38e1aeec2c8aa9338310d29d337bebbd7baa06889d09a60a1fa2", - "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7", - "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.1.1" - }, - "oauth2client": { - "hashes": [ - "sha256:b8a81cc5d60e2d364f0b1b98f958dbd472887acaf1a5b05e21c28c31a2d6d3ac", - "sha256:d486741e451287f69568a4d26d70d9acd73a2bbfa275746c535b4209891cccc6" - ], - "index": "pypi", - "version": "==4.1.3" - }, - "oauthlib": { - "hashes": [ - "sha256:bee41cc35fcca6e988463cacc3bcb8a96224f470ca547e697b604cc697b2f889", - "sha256:df884cd6cbe20e32633f1db1072e9356f53638e4361bef4e8b03c9127c9328ea" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==3.1.0" - }, - "packaging": { - "hashes": [ - "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8", - "sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181" - ], - "index": "pypi", - "version": "==20.4" - }, - "protobuf": { - "hashes": [ - "sha256:0e247612fadda953047f53301a7b0407cb0c3cb4ae25a6fde661597a04039b3c", - "sha256:0fc96785262042e4863b3f3b5c429d4636f10d90061e1840fce1baaf59b1a836", - "sha256:1c51fda1bbc9634246e7be6016d860be01747354ed7015ebe38acf4452f470d2", - "sha256:1d63eb389347293d8915fb47bee0951c7b5dab522a4a60118b9a18f33e21f8ce", - "sha256:22bcd2e284b3b1d969c12e84dc9b9a71701ec82d8ce975fdda19712e1cfd4e00", - "sha256:2a7e2fe101a7ace75e9327b9c946d247749e564a267b0515cf41dfe450b69bac", - "sha256:43b554b9e73a07ba84ed6cf25db0ff88b1e06be610b37656e292e3cbb5437472", - "sha256:4b74301b30513b1a7494d3055d95c714b560fbb630d8fb9956b6f27992c9f980", - "sha256:4e75105c9dfe13719b7293f75bd53033108f4ba03d44e71db0ec2a0e8401eafd", - "sha256:5b7a637212cc9b2bcf85dd828b1178d19efdf74dbfe1ddf8cd1b8e01fdaaa7f5", - "sha256:5e9806a43232a1fa0c9cf5da8dc06f6910d53e4390be1fa06f06454d888a9142", - "sha256:629b03fd3caae7f815b0c66b41273f6b1900a579e2ccb41ef4493a4f5fb84f3a", - "sha256:72230ed56f026dd664c21d73c5db73ebba50d924d7ba6b7c0d81a121e390406e", - "sha256:86a75477addde4918e9a1904e5c6af8d7b691f2a3f65587d73b16100fbe4c3b2", - "sha256:8971c421dbd7aad930c9bd2694122f332350b6ccb5202a8b7b06f3f1a5c41ed5", - "sha256:9616f0b65a30851e62f1713336c931fcd32c057202b7ff2cfbfca0fc7d5e3043", - "sha256:b0d5d35faeb07e22a1ddf8dce620860c8fe145426c02d1a0ae2688c6e8ede36d", - "sha256:ecc33531a213eee22ad60e0e2aaea6c8ba0021f0cce35dbf0ab03dee6e2a23a1" - ], - "version": "==3.14.0" - }, - "psycopg2-binary": { - "hashes": [ - "sha256:ad20d2eb875aaa1ea6d0f2916949f5c08a19c74d05b16ce6ebf6d24f2c9f75d1", - "sha256:e1f57aa70d3f7cc6947fd88636a481638263ba04a742b4a37dd25c373e41491a", - "sha256:833709a5c66ca52f1d21d41865a637223b368c0ee76ea54ca5bad6f2526c7679", - "sha256:11b9c0ebce097180129e422379b824ae21c8f2a6596b159c7659e2e5a00e1aa0", - "sha256:aaa4213c862f0ef00022751161df35804127b78adf4a2755b9f991a507e425fd", - "sha256:ba28584e6bca48c59eecbf7efb1576ca214b47f05194646b081717fa628dfddf", - "sha256:ac0c682111fbf404525dfc0f18a8b5f11be52657d4f96e9fcb75daf4f3984859", - "sha256:6422f2ff0919fd720195f64ffd8f924c1395d30f9a495f31e2392c2efafb5056", - "sha256:0deac2af1a587ae12836aa07970f5cb91964f05a7c6cdb69d8425ff4c15d4e2c", - "sha256:bd1be66dde2b82f80afb9459fc618216753f67109b859a361cf7def5c7968729", - "sha256:1fabed9ea2acc4efe4671b92c669a213db744d2af8a9fc5d69a8e9bc14b7a9db", - "sha256:d1b4ab59e02d9008efe10ceabd0b31e79519da6fb67f7d8e8977118832d0f449", - "sha256:b8a3715b3c4e604bcc94c90a825cd7f5635417453b253499664f784fc4da0152", - "sha256:d14b140a4439d816e3b1229a4a525df917d6ea22a0771a2a78332273fd9528a4", - "sha256:ee69dad2c7155756ad114c02db06002f4cded41132cc51378e57aad79cc8e4f4", - "sha256:7312e931b90fe14f925729cde58022f5d034241918a5c4f9797cac62f6b3a9dd", - "sha256:15978a1fbd225583dd8cdaf37e67ccc278b5abecb4caf6b2d6b8e2b948e953f6", - "sha256:d5227b229005a696cc67676e24c214740efd90b148de5733419ac9aaba3773da", - "sha256:a0c50db33c32594305b0ef9abc0cb7db13de7621d2cadf8392a1d9b3c437ef77", - "sha256:e74a55f6bad0e7d3968399deb50f61f4db1926acf4a6d83beaaa7df986f48b1c", - "sha256:e82aba2188b9ba309fd8e271702bd0d0fc9148ae3150532bbb474f4590039ffb", - "sha256:8cd0fb36c7412996859cb4606a35969dd01f4ea34d9812a141cd920c3b18be77", - "sha256:89705f45ce07b2dfa806ee84439ec67c5d9a0ef20154e0e475e2b2ed392a5b83", - "sha256:2dac98e85565d5688e8ab7bdea5446674a83a3945a8f416ad0110018d1501b94", - "sha256:ba381aec3a5dc29634f20692349d73f2d21f17653bda1decf0b52b11d694541f", - "sha256:7d92a09b788cbb1aec325af5fcba9fed7203897bbd9269d5691bb1e3bce29550", - "sha256:c2507d796fca339c8fb03216364cca68d87e037c1f774977c8fc377627d01c71", - "sha256:a0eb43a07386c3f1f1ebb4dc7aafb13f67188eab896e7397aa1ee95a9c884eb2", - "sha256:b4afc542c0ac0db720cf516dd20c0846f71c248d2b3d21013aa0d4ef9c71ca25", - "sha256:f5ab93a2cb2d8338b1674be43b442a7f544a0971da062a5da774ed40587f18f5", - "sha256:950bc22bb56ee6ff142a2cb9ee980b571dd0912b0334aa3fe0fe3788d860bea2", - "sha256:42ec1035841b389e8cc3692277a0bd81cdfe0b65d575a2c8862cec7a80e62e52", - "sha256:6a32f3a4cb2f6e1a0b15215f448e8ce2da192fd4ff35084d80d5e39da683e79b", - "sha256:cec7e622ebc545dbb4564e483dd20e4e404da17ae07e06f3e780b2dacd5cee66", - "sha256:0e4dc3d5996760104746e6cfcdb519d9d2cd27c738296525d5867ea695774e67" - ], - "index": "pypi", - "version": "==2.8.6" - }, - "pyasn1": { - "hashes": [ - "sha256:014c0e9976956a08139dc0712ae195324a75e142284d5f87f1a87ee1b068a359", - "sha256:03840c999ba71680a131cfaee6fab142e1ed9bbd9c693e285cc6aca0d555e576", - "sha256:0458773cfe65b153891ac249bcf1b5f8f320b7c2ce462151f8fa74de8934becf", - "sha256:08c3c53b75eaa48d71cf8c710312316392ed40899cb34710d092e96745a358b7", - "sha256:39c7e2ec30515947ff4e87fb6f456dfc6e84857d34be479c9d4a4ba4bf46aa5d", - "sha256:5c9414dcfede6e441f7e8f81b43b34e834731003427e5b09e4e00e3172a10f00", - "sha256:6e7545f1a61025a4e58bb336952c5061697da694db1cae97b116e9c46abcf7c8", - "sha256:78fa6da68ed2727915c4767bb386ab32cdba863caa7dbe473eaae45f9959da86", - "sha256:7ab8a544af125fb704feadb008c99a88805126fb525280b2270bb25cc1d78a12", - "sha256:99fcc3c8d804d1bc6d9a099921e39d827026409a58f2a720dcdb89374ea0c776", - "sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba", - "sha256:e89bf84b5437b532b0803ba5c9a5e054d21fec423a89952a74f87fa2c9b7bce2", - "sha256:fec3e9d8e36808a28efb59b489e4528c10ad0f480e57dcc32b4de5c9d8c9fdf3" - ], - "version": "==0.4.8" - }, - "pyasn1-modules": { - "hashes": [ - "sha256:0845a5582f6a02bb3e1bde9ecfc4bfcae6ec3210dd270522fee602365430c3f8", - "sha256:0fe1b68d1e486a1ed5473f1302bd991c1611d319bba158e98b106ff86e1d7199", - "sha256:15b7c67fabc7fc240d87fb9aabf999cf82311a6d6fb2c70d00d3d0604878c811", - "sha256:426edb7a5e8879f1ec54a1864f16b882c2837bfd06eee62f2c982315ee2473ed", - "sha256:65cebbaffc913f4fe9e4808735c95ea22d7a7775646ab690518c056784bc21b4", - "sha256:905f84c712230b2c592c19470d3ca8d552de726050d1d1716282a1f6146be65e", - "sha256:a50b808ffeb97cb3601dd25981f6b016cbb3d31fbf57a8b8a87428e6158d0c74", - "sha256:a99324196732f53093a84c4369c996713eb8c89d360a496b599fb1a9c47fc3eb", - "sha256:b80486a6c77252ea3a3e9b1e360bc9cf28eaac41263d173c032581ad2f20fe45", - "sha256:c29a5e5cc7a3f05926aff34e097e84f8589cd790ce0ed41b67aed6857b26aafd", - "sha256:cbac4bc38d117f2a49aeedec4407d23e8866ea4ac27ff2cf7fb3e5b570df19e0", - "sha256:f39edd8c4ecaa4556e989147ebf219227e2cd2e8a43c7e7fcb1f1c18c5fd6a3d", - "sha256:fe0644d9ab041506b62782e92b06b8c68cca799e1a9636ec398675459e031405" - ], - "version": "==0.2.8" - }, - "pycparser": { - "hashes": [ - "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0", - "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==2.20" - }, - "pyjwt": { - "hashes": [ - "sha256:5c6eca3c2940464d106b99ba83b00c6add741c9becaec087fb7ccdefea71350e", - "sha256:8d59a976fb773f3e6a39c85636357c4f0e242707394cadadd9814f5cbaa20e96" - ], - "version": "==1.7.1" - }, - "pyotp": { - "hashes": [ - "sha256:038a3f70b34eaad3f72459e8b411662ef8dfcdd95f7d9203fa489e987a75584b", - "sha256:ef07c393660529261e66902e788b32e46260d2c29eb740978df778260a1c2b4c" - ], - "version": "==2.4.1" - }, - "pyparsing": { - "hashes": [ - "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", - "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" - ], - "index": "pypi", - "version": "==2.4.7" - }, - "python-dateutil": { - "hashes": [ - "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c", - "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==2.8.1" - }, - "python-http-client": { - "hashes": [ - "sha256:0a5855902cede46775912d418a23f05fe6f5d60371df1084bef8c219218ce8d9", - "sha256:7e430f4b9dd2b621b0051f6a362f103447ea8e267594c602a5c502a0c694ee38", - "sha256:84267d8dcb7bcdf4c5cef321a533cc584c5b52159d4a4d3d4139bfed347b8006" - ], - "index": "pypi", - "version": "==3.1.0" - }, - "python3-openid": { - "hashes": [ - "sha256:33fbf6928f401e0b790151ed2b5290b02545e8775f982485205a066f874aaeaf", - "sha256:6626f771e0417486701e0b4daff762e7212e820ca5b29fcc0d05f6f8736dfa6b" - ], - "markers": "python_version >= '3.0'", - "version": "==3.2.0" - }, - "pytz": { - "hashes": [ - "sha256:3e6b7dd2d1e0a59084bcee14a17af60c5c562cdc16d828e8eba2e683d3a7e268", - "sha256:5c55e189b682d420be27c6995ba6edce0c0a77dd67bfbe2ae6607134d5851ffd" - ], - "version": "==2020.4" - }, - "requests": { - "hashes": [ - "sha256:7f1a0b932f4a60a1a65caa4263921bb7d9ee911957e0ae4a23a6dd08185ad5f8", - "sha256:e786fa28d8c9154e6a4de5d46a1d921b8749f8b74e28bde23768e5e16eece998" - ], - "index": "pypi", - "version": "==2.25.0" - }, - "requests-oauthlib": { - "hashes": [ - "sha256:7f71572defaecd16372f9006f33c2ec8c077c3cfa6f5911a9a90202beb513f3d", - "sha256:b4261601a71fd721a8bd6d7aa1cc1d6a8a93b4a9f5e96626f8e4d91e8beeaa6a", - "sha256:fa6c47b933f01060936d87ae9327fead68768b69c6c9ea2109c48be30f2d4dbc" - ], - "version": "==1.3.0" - }, - "rsa": { - "hashes": [ - "sha256:109ea5a66744dd859bf16fe904b8d8b627adafb9408753161e766a92e7d681fa", - "sha256:6166864e23d6b5195a5cfed6cd9fed0fe774e226d8f854fcb23b7bbef0350233" - ], - "markers": "python_version >= '3.5'", - "version": "==4.6" - }, - "ruamel.yaml": { - "hashes": [ - "sha256:012b9470a0ea06e4e44e99e7920277edf6b46eee0232a04487ea73a7386340a5", - "sha256:076cc0bc34f1966d920a49f18b52b6ad559fbe656a0748e3535cf7b3f29ebf9e" - ], - "version": "==0.16.12" - }, - "ruamel.yaml.clib": { - "hashes": [ - "sha256:058a1cc3df2a8aecc12f983a48bda99315cebf55a3b3a5463e37bb599b05727b", - "sha256:2602e91bd5c1b874d6f93d3086f9830f3e907c543c7672cf293a97c3fabdcd91", - "sha256:28116f204103cb3a108dfd37668f20abe6e3cafd0d3fd40dba126c732457b3cc", - "sha256:2d24bd98af676f4990c4d715bcdc2a60b19c56a3fb3a763164d2d8ca0e806ba7", - "sha256:30dca9bbcbb1cc858717438218d11eafb78666759e5094dd767468c0d577a7e7", - "sha256:44c7b0498c39f27795224438f1a6be6c5352f82cb887bc33d962c3a3acc00df6", - "sha256:464e66a04e740d754170be5e740657a3b3b6d2bcc567f0c3437879a6e6087ff6", - "sha256:4df5019e7783d14b79217ad9c56edf1ba7485d614ad5a385d1b3c768635c81c0", - "sha256:4e52c96ca66de04be42ea2278012a2342d89f5e82b4512fb6fb7134e377e2e62", - "sha256:5254af7d8bdf4d5484c089f929cb7f5bafa59b4f01d4f48adda4be41e6d29f99", - "sha256:52ae5739e4b5d6317b52f5b040b1b6639e8af68a5b8fd606a8b08658fbd0cab5", - "sha256:53b9dd1abd70e257a6e32f934ebc482dac5edb8c93e23deb663eac724c30b026", - "sha256:73b3d43e04cc4b228fa6fa5d796409ece6fcb53a6c270eb2048109cbcbc3b9c2", - "sha256:74161d827407f4db9072011adcfb825b5258a5ccb3d2cd518dd6c9edea9e30f1", - "sha256:839dd72545ef7ba78fd2aa1a5dd07b33696adf3e68fae7f31327161c1093001b", - "sha256:8e8fd0a22c9d92af3a34f91e8a2594eeb35cba90ab643c5e0e643567dc8be43e", - "sha256:a873e4d4954f865dcb60bdc4914af7eaae48fb56b60ed6daa1d6251c72f5337c", - "sha256:ab845f1f51f7eb750a78937be9f79baea4a42c7960f5a94dde34e69f3cce1988", - "sha256:b1e981fe1aff1fd11627f531524826a4dcc1f26c726235a52fcb62ded27d150f", - "sha256:b4b0d31f2052b3f9f9b5327024dc629a253a83d8649d4734ca7f35b60ec3e9e5", - "sha256:c6ac7e45367b1317e56f1461719c853fd6825226f45b835df7436bb04031fd8a", - "sha256:daf21aa33ee9b351f66deed30a3d450ab55c14242cfdfcd377798e2c0d25c9f1", - "sha256:e9f7d1d8c26a6a12c23421061f9022bb62704e38211fe375c645485f38df34a2", - "sha256:f6061a31880c1ed6b6ce341215336e2f3d0c1deccd84957b6fa8ca474b41e89f" - ], - "markers": "python_version < '3.9' and platform_python_implementation == 'CPython'", - "version": "==0.2.2" - }, - "rx": { - "hashes": [ - "sha256:0e0f2715a3452e95dcb5d6ea28ffe5742e832592bbcc67a48f394ef8ba871e6f", - "sha256:562851cfdb27fd5a218443cdbd682029684144dbafeb5dce34f6a709511282de" - ], - "markers": "python_full_version >= '3.6.0'", - "version": "==3.1.1" - }, - "s3transfer": { - "hashes": [ - "sha256:2482b4259524933a022d59da830f51bd746db62f047d6eb213f2f8855dcb8a13", - "sha256:921a37e2aefc64145e7b73d50c71bb4f26f46e4c9f414dc648c6245ff92cf7db" - ], - "version": "==0.3.3" - }, - "sendgrid": { - "hashes": [ - "sha256:9fba62068dd13922004b6a1676e21c6435709aaf7c2b978cdf1206e3d2196c60", - "sha256:d1af52f8cbb900bf79e28aa08102a503f5e26489c7b9f1d9750758a4b27c84d1" - ], - "version": "==3.6.5" - }, - "sendgrid-django": { - "hashes": [ - "sha256:fef60ba37d588e5e1c4dcbb3b8b7f8c16920e3e89432de357f6abd6157c4f5f4" - ], - "index": "pypi", - "version": "==4.2.0" - }, - "shortuuid": { - "hashes": [ - "sha256:3c11d2007b915c43bee3e10625f068d8a349e04f0d81f08f5fa08507427ebf1f", - "sha256:492c7402ff91beb1342a5898bd61ea953985bf24a41cd9f247409aa2e03c8f77" - ], - "index": "pypi", - "version": "==1.0.1" - }, - "six": { - "hashes": [ - "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", - "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" - ], - "index": "pypi", - "version": "==1.15.0" - }, - "smsapi-client": { - "hashes": [ - "sha256:24dbc95271643268fec3995ee2630165ac2abaa72795bef4945a6ed8f56f81da", - "sha256:81677a9fde0701557f0b8a8009912a817945ebd7e7e8cb8643dc426b7ec90974" - ], - "version": "==2.4.3" - }, - "social-auth-app-django": { - "hashes": [ - "sha256:2c69e57df0b30c9c1823519c5f1992cbe4f3f98fdc7d95c840e091a752708840", - "sha256:567ad0e028311541d7dfed51d3bf2c60440a6fd236d5d4d06c5a618b3d6c57c5", - "sha256:df5212370bd250108987c4748419a1a1d0cec750878856c2644c36aaa0fd3e58" - ], - "version": "==4.0.0" - }, - "social-auth-core": { - "hashes": [ - "sha256:21c0639c56befd33ec162c2210d583bb1de8e1136d53b21bafb96afaf2f86c91", - "sha256:2f6ce1af8ec2b2cc37b86d647f7d4e4292f091ee556941db34b1e0e2dee77fc0", - "sha256:4a3cdf69c449b235cdabd54a1be7ba3722611297e69fded52e3584b1a990af25" - ], - "version": "==3.3.3" - }, - "sqlparse": { - "hashes": [ - "sha256:017cde379adbd6a1f15a61873f43e8274179378e95ef3fede90b5aa64d304ed0", - "sha256:0f91fd2e829c44362cbcfab3e9ae12e22badaa8a29ad5ff599f9ec109f0454e8" - ], - "markers": "python_version >= '3.5'", - "version": "==0.4.1" - }, - "twilio": { - "hashes": [ - "sha256:effb4d6e9e9a9069065fbe21dea844597376ae6d6333626f14b05ba6b35bbb22" - ], - "version": "==6.47.0" - }, - "uritemplate": { - "hashes": [ - "sha256:07620c3f3f8eed1f12600845892b0e036a2420acf513c53f7de0abd911a5894f", - "sha256:5af8ad10cec94f215e3f48112de2022e1d5a37ed427fbd88652fa908f2ab7cae" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==3.0.1" - }, - "urllib3": { - "hashes": [ - "sha256:19188f96923873c92ccb987120ec4acaa12f0461fa9ce5d3d0772bc965a39e08", - "sha256:d8ff90d979214d7b4f8ce956e80f4028fc6860e4431f731ea4a8c08f23f99473" - ], - "markers": "python_version != '3.4'", - "version": "==1.26.2" - }, - "whitenoise": { - "hashes": [ - "sha256:15f43b2e701821b95c9016cf469d29e2a546cb1c7dead584ba82c36f843995cf", - "sha256:9d81515f2b5b27051910996e1e860b1332e354d9e7bcf30c98f21dcb6713e0dd" - ], - "index": "pypi", - "version": "==3.3.1" - }, - "yubico-client": { - "hashes": [ - "sha256:59d818661f638e3f041fae44ba2c0569e4eb2a17865fa7cc9ad6577185c4d185", - "sha256:e3b86cd2a123105edfacad40551c7b26e9c1193d81ffe168ee704ebfd3d11162" - ], - "version": "==1.13.0" - } - }, - "develop": { - "appdirs": { - "hashes": [ - "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41", - "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128" - ], - "index": "pypi", - "version": "==1.4.4" - }, - "astroid": { - "hashes": [ - "sha256:2f4078c2a41bf377eea06d71c9d2ba4eb8f6b1af2135bec27bbbb7d8f12bb703", - "sha256:bc58d83eb610252fd8de6363e39d4f1d0619c894b0ed24603b881c02e64c7386" - ], - "markers": "python_version >= '3.5'", - "version": "==2.4.2" - }, - "attrs": { - "hashes": [ - "sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6", - "sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==20.3.0" - }, - "autopep8": { - "hashes": [ - "sha256:d21d3901cb0da6ebd1e83fc9b0dfbde8b46afc2ede4fe32fbda0c7c6118ca094" - ], - "index": "pypi", - "version": "==1.5.4" - }, - "black": { - "hashes": [ - "sha256:1c02557aa099101b9d21496f8a914e9ed2222ef70336404eeeac8edba836fbea" - ], - "index": "pypi", - "version": "==20.8b1" - }, - "click": { - "hashes": [ - "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a", - "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", - "version": "==7.1.2" - }, - "django-test-migrations": { - "hashes": [ - "sha256:d120d0287e1dd82ed62fe083747a1e99c0398d56beda52594e8391b94a41bef5", - "sha256:e5747e2ad0b7e4d3b8d9ccd40d414b0f186316d3757af022b4bbdec700897521" - ], - "index": "pypi", - "version": "==1.0.0" - }, - "iniconfig": { - "hashes": [ - "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3", - "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32" - ], - "version": "==1.1.1" - }, - "isort": { - "hashes": [ - "sha256:dcab1d98b469a12a1a624ead220584391648790275560e1a43e54c5dceae65e7", - "sha256:dcaeec1b5f0eca77faea2a35ab790b4f3680ff75590bfcb7145986905aab2f58" - ], - "markers": "python_version >= '3.6' and python_version < '4.0'", - "version": "==5.6.4" - }, - "lazy-object-proxy": { - "hashes": [ - "sha256:0c4b206227a8097f05c4dbdd323c50edf81f15db3b8dc064d08c62d37e1a504d", - "sha256:194d092e6f246b906e8f70884e620e459fc54db3259e60cf69a4d66c3fda3449", - "sha256:1be7e4c9f96948003609aa6c974ae59830a6baecc5376c25c92d7d697e684c08", - "sha256:4677f594e474c91da97f489fea5b7daa17b5517190899cf213697e48d3902f5a", - "sha256:48dab84ebd4831077b150572aec802f303117c8cc5c871e182447281ebf3ac50", - "sha256:5541cada25cd173702dbd99f8e22434105456314462326f06dba3e180f203dfd", - "sha256:59f79fef100b09564bc2df42ea2d8d21a64fdcda64979c0fa3db7bdaabaf6239", - "sha256:8d859b89baf8ef7f8bc6b00aa20316483d67f0b1cbf422f5b4dc56701c8f2ffb", - "sha256:9254f4358b9b541e3441b007a0ea0764b9d056afdeafc1a5569eee1cc6c1b9ea", - "sha256:9651375199045a358eb6741df3e02a651e0330be090b3bc79f6d0de31a80ec3e", - "sha256:97bb5884f6f1cdce0099f86b907aa41c970c3c672ac8b9c8352789e103cf3156", - "sha256:9b15f3f4c0f35727d3a0fba4b770b3c4ebbb1fa907dbcc046a1d2799f3edd142", - "sha256:a2238e9d1bb71a56cd710611a1614d1194dc10a175c1e08d75e1a7bcc250d442", - "sha256:a6ae12d08c0bf9909ce12385803a543bfe99b95fe01e752536a60af2b7797c62", - "sha256:ca0a928a3ddbc5725be2dd1cf895ec0a254798915fb3a36af0964a0a4149e3db", - "sha256:cb2c7c57005a6804ab66f106ceb8482da55f5314b7fcb06551db1edae4ad1531", - "sha256:d74bb8693bf9cf75ac3b47a54d716bbb1a92648d5f781fc799347cfc95952383", - "sha256:d945239a5639b3ff35b70a88c5f2f491913eb94871780ebfabb2568bd58afc5a", - "sha256:eba7011090323c1dadf18b3b689845fd96a61ba0a1dfbd7f24b921398affc357", - "sha256:efa1909120ce98bbb3777e8b6f92237f5d5c8ea6758efea36a473e1d38f7d3e4", - "sha256:f3900e8a5de27447acbf900b4750b0ddfd7ec1ea7fbaf11dfa911141bc522af0" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.4.3" - }, - "mccabe": { - "hashes": [ - "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", - "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f" - ], - "version": "==0.6.1" - }, - "mypy-extensions": { - "hashes": [ - "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d", - "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8" - ], - "version": "==0.4.3" - }, - "packaging": { - "hashes": [ - "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8", - "sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181" - ], - "index": "pypi", - "version": "==20.4" - }, - "pathspec": { - "hashes": [ - "sha256:86379d6b86d75816baba717e64b1a3a3469deb93bb76d613c9ce79edc5cb68fd", - "sha256:aa0cb481c4041bf52ffa7b0d8fa6cd3e88a2ca4879c533c9153882ee2556790d" - ], - "version": "==0.8.1" - }, - "pep8": { - "hashes": [ - "sha256:b22cfae5db09833bb9bd7c8463b53e1a9c9b39f12e304a8d0bba729c501827ee", - "sha256:fe249b52e20498e59e0b5c5256aa52ee99fc295b26ec9eaa85776ffdb9fe6374" - ], - "index": "pypi", - "version": "==1.7.1" - }, - "pluggy": { - "hashes": [ - "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0", - "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==0.13.1" - }, - "py": { - "hashes": [ - "sha256:366389d1db726cd2fcfc79732e75410e5fe4d31db13692115529d34069a043c2", - "sha256:9ca6883ce56b4e8da7e79ac18787889fa5206c79dcc67fb065376cd2fe03f342" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.9.0" - }, - "pycodestyle": { - "hashes": [ - "sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367", - "sha256:c58a7d2815e0e8d7972bf1803331fb0152f867bd89adf8a01dfd55085434192e" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==2.6.0" - }, - "pylint": { - "hashes": [ - "sha256:bb4a908c9dadbc3aac18860550e870f58e1a02c9f2c204fdf5693d73be061210", - "sha256:bfe68f020f8a0fece830a22dd4d5dddb4ecc6137db04face4c3420a46a52239f" - ], - "index": "pypi", - "version": "==2.6.0" - }, - "pyparsing": { - "hashes": [ - "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", - "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" - ], - "index": "pypi", - "version": "==2.4.7" - }, - "pytest": { - "hashes": [ - "sha256:4288fed0d9153d9646bfcdf0c0428197dba1ecb27a33bb6e031d002fa88653fe", - "sha256:c0a7e94a8cdbc5422a51ccdad8e6f1024795939cc89159a0ae7f0b316ad3823e" - ], - "index": "pypi", - "version": "==6.1.2" - }, - "pytest-django": { - "hashes": [ - "sha256:10e384e6b8912ded92db64c58be8139d9ae23fb8361e5fc139d8e4f8fc601bc2", - "sha256:26f02c16d36fd4c8672390deebe3413678d89f30720c16efb8b2a6bf63b9041f" - ], - "index": "pypi", - "version": "==4.1.0" - }, - "regex": { - "hashes": [ - "sha256:02951b7dacb123d8ea6da44fe45ddd084aa6777d4b2454fa0da61d569c6fa538", - "sha256:0d08e71e70c0237883d0bef12cad5145b84c3705e9c6a588b2a9c7080e5af2a4", - "sha256:1862a9d9194fae76a7aaf0150d5f2a8ec1da89e8b55890b1786b8f88a0f619dc", - "sha256:1ab79fcb02b930de09c76d024d279686ec5d532eb814fd0ed1e0051eb8bd2daa", - "sha256:1fa7ee9c2a0e30405e21031d07d7ba8617bc590d391adfc2b7f1e8b99f46f444", - "sha256:262c6825b309e6485ec2493ffc7e62a13cf13fb2a8b6d212f72bd53ad34118f1", - "sha256:2a11a3e90bd9901d70a5b31d7dd85114755a581a5da3fc996abfefa48aee78af", - "sha256:2c99e97d388cd0a8d30f7c514d67887d8021541b875baf09791a3baad48bb4f8", - "sha256:3128e30d83f2e70b0bed9b2a34e92707d0877e460b402faca908c6667092ada9", - "sha256:38c8fd190db64f513fe4e1baa59fed086ae71fa45083b6936b52d34df8f86a88", - "sha256:3bddc701bdd1efa0d5264d2649588cbfda549b2899dc8d50417e47a82e1387ba", - "sha256:4902e6aa086cbb224241adbc2f06235927d5cdacffb2425c73e6570e8d862364", - "sha256:49cae022fa13f09be91b2c880e58e14b6da5d10639ed45ca69b85faf039f7a4e", - "sha256:56e01daca75eae420bce184edd8bb341c8eebb19dd3bce7266332258f9fb9dd7", - "sha256:5862975b45d451b6db51c2e654990c1820523a5b07100fc6903e9c86575202a0", - "sha256:6a8ce43923c518c24a2579fda49f093f1397dad5d18346211e46f134fc624e31", - "sha256:6c54ce4b5d61a7129bad5c5dc279e222afd00e721bf92f9ef09e4fae28755683", - "sha256:6e4b08c6f8daca7d8f07c8d24e4331ae7953333dbd09c648ed6ebd24db5a10ee", - "sha256:717881211f46de3ab130b58ec0908267961fadc06e44f974466d1887f865bd5b", - "sha256:749078d1eb89484db5f34b4012092ad14b327944ee7f1c4f74d6279a6e4d1884", - "sha256:7913bd25f4ab274ba37bc97ad0e21c31004224ccb02765ad984eef43e04acc6c", - "sha256:7a25fcbeae08f96a754b45bdc050e1fb94b95cab046bf56b016c25e9ab127b3e", - "sha256:83d6b356e116ca119db8e7c6fc2983289d87b27b3fac238cfe5dca529d884562", - "sha256:8b882a78c320478b12ff024e81dc7d43c1462aa4a3341c754ee65d857a521f85", - "sha256:8f6a2229e8ad946e36815f2a03386bb8353d4bde368fdf8ca5f0cb97264d3b5c", - "sha256:9801c4c1d9ae6a70aeb2128e5b4b68c45d4f0af0d1535500884d644fa9b768c6", - "sha256:a15f64ae3a027b64496a71ab1f722355e570c3fac5ba2801cafce846bf5af01d", - "sha256:a3d748383762e56337c39ab35c6ed4deb88df5326f97a38946ddd19028ecce6b", - "sha256:a63f1a07932c9686d2d416fb295ec2c01ab246e89b4d58e5fa468089cab44b70", - "sha256:b2b1a5ddae3677d89b686e5c625fc5547c6e492bd755b520de5332773a8af06b", - "sha256:b2f4007bff007c96a173e24dcda236e5e83bde4358a557f9ccf5e014439eae4b", - "sha256:baf378ba6151f6e272824b86a774326f692bc2ef4cc5ce8d5bc76e38c813a55f", - "sha256:bafb01b4688833e099d79e7efd23f99172f501a15c44f21ea2118681473fdba0", - "sha256:bba349276b126947b014e50ab3316c027cac1495992f10e5682dc677b3dfa0c5", - "sha256:c084582d4215593f2f1d28b65d2a2f3aceff8342aa85afd7be23a9cad74a0de5", - "sha256:d1ebb090a426db66dd80df8ca85adc4abfcbad8a7c2e9a5ec7513ede522e0a8f", - "sha256:d2d8ce12b7c12c87e41123997ebaf1a5767a5be3ec545f64675388970f415e2e", - "sha256:e32f5f3d1b1c663af7f9c4c1e72e6ffe9a78c03a31e149259f531e0fed826512", - "sha256:e3faaf10a0d1e8e23a9b51d1900b72e1635c2d5b0e1bea1c18022486a8e2e52d", - "sha256:f7d29a6fc4760300f86ae329e3b6ca28ea9c20823df123a2ea8693e967b29917", - "sha256:f8f295db00ef5f8bae530fc39af0b40486ca6068733fb860b42115052206466f" - ], - "version": "==2020.11.13" - }, - "six": { - "hashes": [ - "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", - "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" - ], - "index": "pypi", - "version": "==1.15.0" - }, - "toml": { - "hashes": [ - "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", - "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f" - ], - "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==0.10.2" - }, - "typed-ast": { - "hashes": [ - "sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355", - "sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919", - "sha256:0d8110d78a5736e16e26213114a38ca35cb15b6515d535413b090bd50951556d", - "sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa", - "sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652", - "sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75", - "sha256:3742b32cf1c6ef124d57f95be609c473d7ec4c14d0090e5a5e05a15269fb4d0c", - "sha256:4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01", - "sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d", - "sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1", - "sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907", - "sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c", - "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3", - "sha256:7e4c9d7658aaa1fc80018593abdf8598bf91325af6af5cce4ce7c73bc45ea53d", - "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b", - "sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614", - "sha256:92c325624e304ebf0e025d1224b77dd4e6393f18aab8d829b5b7e04afe9b7a2c", - "sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb", - "sha256:b52ccf7cfe4ce2a1064b18594381bccf4179c2ecf7f513134ec2f993dd4ab395", - "sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b", - "sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41", - "sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6", - "sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34", - "sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe", - "sha256:d648b8e3bf2fe648745c8ffcee3db3ff903d0817a01a12dd6a6ea7a8f4889072", - "sha256:f208eb7aff048f6bea9586e61af041ddf7f9ade7caed625742af423f6bae3298", - "sha256:fac11badff8313e23717f3dada86a15389d0708275bddf766cca67a84ead3e91", - "sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4", - "sha256:fcf135e17cc74dbfbc05894ebca928ffeb23d9790b3167a674921db19082401f", - "sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7" - ], - "version": "==1.4.1" - }, - "typing-extensions": { - "hashes": [ - "sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918", - "sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c", - "sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f" - ], - "version": "==3.7.4.3" - }, - "wrapt": { - "hashes": [ - "sha256:b62ffa81fb85f4332a4f609cab4ac40709470da05643a082ec1eb88e6d9b97d7" - ], - "version": "==1.12.1" - } - } -} From 8c0d689db45873f2d0cbc4d4e034c34ea6c2f413 Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Mon, 23 Nov 2020 10:24:33 +0000 Subject: [PATCH 28/47] Feature/improve ci process --- .gitlab-ci.yml | 88 +++++++++++++++++++------------------- .gitmodules | 0 Dockerfile | 13 ------ Dockerfile.dev | 21 --------- docker/Dockerfile.ci | 18 ++++++++ generate.sh | 17 -------- gitlab-docker/Dockerfile | 8 ---- src/app/settings/common.py | 36 ++++++++-------- 8 files changed, 78 insertions(+), 123 deletions(-) delete mode 100644 .gitmodules delete mode 100644 Dockerfile delete mode 100644 Dockerfile.dev create mode 100644 docker/Dockerfile.ci delete mode 100644 generate.sh delete mode 100644 gitlab-docker/Dockerfile diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 5052df942fc7..f69ae8712cc4 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,65 +1,63 @@ stages: - - test - - deploy - - deploy-aws + - test + - deploy + +cache: + key: ${CI_COMMIT_REF_SLUG} + paths: + - .venv/ test: - image: python:3.7 + image: python:3.7-slim stage: test services: - - postgres:10.9 + - postgres:10.9-alpine variables: DJANGO_SETTINGS_MODULE: "app.settings.test" DATABASE_URL: postgres://testuser:testpass@postgres/test_db POSTGRES_USER: testuser POSTGRES_PASSWORD: testpass script: + - pip install virtualenv # set up local venv dir for caching of packages + - virtualenv .venv + - source .venv/bin/activate - pip install -r requirements-dev.txt - black --check . - pytest src -p no:warnings deploydevelop: - image: ilyasemenov/gitlab-ci-git-push - stage: deploy - script: git-push dokku@bitwarden.bullet-train.io:bullet-train - only: - - develop + image: ilyasemenov/gitlab-ci-git-push + stage: deploy + script: git-push dokku@bitwarden.bullet-train.io:bullet-train + only: + - develop + +.deploy_to_beanstalk: &deploy_to_beanstalk | + echo "Deploying to beanstalk with label $CI_COMMIT_SHORT_SHA" + cp requirements.txt ./src/requirements.txt + cd src + eb deploy $ENVIRONMENT_NAME -l "$CI_COMMIT_SHORT_SHA" deployawsstaging: - image: bullettrain/elasticbeanstalk-pipenv # TODO: remove pipenv from this docker image - stage: deploy-aws - script: - - export AWS_ACCESS_KEY_ID=$AWS_STAGING_ACCESS_KEY_ID - - export AWS_SECRET_ACCESS_KEY=$AWS_STAGING_SECRET_ACCESS_KEY - - export DATABASE_URL=$DATABASE_URL_STAGING - - export GOOGLE_ANALYTICS_CLIENT_ID=$GOOGLE_ANALYTICS_CLIENT_ID_STAGING - - export GOOGLE_ANALYTICS_KEY=$GOOGLE_ANALYTICS_KEY_STAGING - - export DJANGO_SETTINGS_MODULE=$DJANGO_SETTINGS_MODULE_STAGING - - cp requirements.txt ./src/requirements.txt - - sh generate.sh - - git config --global user.email "build@gitlab.com" - - git config --global user.name "Gitlab" - - git add . && git commit -m "Commit to EB" - - cd src - - eb deploy $EB_ENVIRONMENT_STAGING - only: - - staging + image: flagsmith/eb-cli:latest + stage: deploy + variables: + ENVIRONMENT_NAME: staging-api + AWS_ACCESS_KEY_ID: "$AWS_STAGING_ACCESS_KEY_ID" + AWS_SECRET_ACCESS_KEY: "$AWS_STAGING_SECRET_ACCESS_KEY" + script: + - *deploy_to_beanstalk + only: + - staging deployawsmaster: - image: bullettrain/elasticbeanstalk-pipenv - stage: deploy-aws - script: - - export DATABASE_URL=$DATABASE_URL_PRODUCTION - - export GOOGLE_ANALYTICS_CLIENT_ID=$GOOGLE_ANALYTICS_CLIENT_ID_PRODUCTION - - export GOOGLE_ANALYTICS_KEY=$GOOGLE_ANALYTICS_KEY_PRODUCTION - - export DJANGO_SETTINGS_MODULE=$DJANGO_SETTINGS_MODULE_PRODUCTION - - cp requirements.txt ./src/requirements.txt - - sh generate.sh - - git config --global user.email "build@gitlab.com" - - git config --global user.name "Gitlab" - - git add . && git commit -m "Commit to EB" - - cd src - - eb deploy $EB_ENVIRONMENT_PRODUCTION --timeout 30 - only: - - master - - master-aws \ No newline at end of file + image: flagsmith/eb-cli:latest + stage: deploy + variables: + ENVIRONMENT_NAME: production-api + AWS_ACCESS_KEY_ID: "$AWS_PRODUCTION_ACCESS_KEY_ID" + AWS_SECERET_ACCESS_KEY: "$AWS_PRODUCTION_SECRET_ACCESS_KEY" + script: + - *deploy_to_beanstalk + only: + - master diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 22f8b6dc5de0..000000000000 --- a/Dockerfile +++ /dev/null @@ -1,13 +0,0 @@ -FROM python:3.8-slim as application - -WORKDIR /app -COPY requirements.txt /app/ -COPY src/ /app/src/ -COPY bin/ /app/bin/ - -RUN pip install -r requirements.txt - -ENV DJANGO_SETTINGS_MODULE=app.settings.master-docker -EXPOSE 8000 - -CMD ["./bin/docker"] diff --git a/Dockerfile.dev b/Dockerfile.dev deleted file mode 100644 index c9df1eee9649..000000000000 --- a/Dockerfile.dev +++ /dev/null @@ -1,21 +0,0 @@ -FROM python:3.8 -ENV PYTHONUNBUFFERED 1 - -RUN rm /var/lib/dpkg/info/format -RUN printf "1\n" > /var/lib/dpkg/info/format -RUN dpkg --configure -a -RUN apt-get clean && apt-get update \ - && apt-get install -y --no-install-recommends \ - postgresql-client \ - && rm -rf /var/lib/apt/lists/* - -RUN mkdir /app -WORKDIR /app -COPY requirements.txt /app/ -COPY src/ /app/src/ -COPY bin/ /app/bin/ - -RUN pip install -r requirements-dev.txt - -ENV DJANGO_SETTINGS_MODULE=app.settings.master-docker -EXPOSE 8000 diff --git a/docker/Dockerfile.ci b/docker/Dockerfile.ci new file mode 100644 index 000000000000..87f1ba7f33cf --- /dev/null +++ b/docker/Dockerfile.ci @@ -0,0 +1,18 @@ +FROM python:3-slim + +ENV BUILD_DIR=/build + +RUN apt-get update +RUN apt-get install -y \ + build-essential zlib1g-dev libssl-dev libncurses-dev git \ + libffi-dev libsqlite3-dev libreadline-dev libbz2-dev curl + +RUN mkdir $BUILD_DIR +WORKDIR ${BUILD_DIR} + +RUN git clone https://github.com/aws/aws-elastic-beanstalk-cli-setup.git + +RUN pip install virtualenv +RUN python aws-elastic-beanstalk-cli-setup/scripts/ebcli_installer.py --location $BUILD_DIR + +ENV PATH="${BUILD_DIR}/.ebcli-virtual-env/bin:${PATH}" diff --git a/generate.sh b/generate.sh deleted file mode 100644 index 9a9ec3c064c9..000000000000 --- a/generate.sh +++ /dev/null @@ -1,17 +0,0 @@ - -#!/bin/bash -echo -e "\nGenerating a options.config file" - - # Generate the file - cat > ./src/.ebextensions/options.config < - -RUN pip install --upgrade awsebcli -RUN pip install --no-cache-dir awsebcli -RUN pip install pipenv - diff --git a/src/app/settings/common.py b/src/app/settings/common.py index 8f46737ef634..34e79980fed7 100644 --- a/src/app/settings/common.py +++ b/src/app/settings/common.py @@ -37,16 +37,16 @@ SECRET_KEY = env("DJANGO_SECRET_KEY", default=get_random_secret_key()) -HOSTED_SEATS_LIMIT = int(os.environ.get("HOSTED_SEATS_LIMIT", 0)) +HOSTED_SEATS_LIMIT = env.int("HOSTED_SEATS_LIMIT", default=0) # Google Analytics Configuration -GOOGLE_ANALYTICS_KEY = os.environ.get("GOOGLE_ANALYTICS_KEY", "") -GOOGLE_SERVICE_ACCOUNT = os.environ.get("GOOGLE_SERVICE_ACCOUNT") +GOOGLE_ANALYTICS_KEY = env("GOOGLE_ANALYTICS_KEY", default="") +GOOGLE_SERVICE_ACCOUNT = env("GOOGLE_SERVICE_ACCOUNT", default=None) 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") +GA_TABLE_ID = env("GA_TABLE_ID", default=None) if not GA_TABLE_ID: warnings.warn( "GA_TABLE_ID not configured, getting organisation usage will not work" @@ -60,9 +60,7 @@ ALLOWED_HOSTS = env.list("DJANGO_ALLOWED_HOSTS", default=[]) CSRF_TRUSTED_ORIGINS = env.list("DJANGO_CSRF_TRUSTED_ORIGINS", default=[]) -INTERNAL_IPS = [ - "127.0.0.1", -] +INTERNAL_IPS = ["127.0.0.1"] # In order to run a load balanced solution, we need to whitelist the internal ip try: @@ -245,7 +243,7 @@ CORS_ORIGIN_ALLOW_ALL = True CORS_ALLOW_HEADERS = default_headers + ("X-Environment-Key", "X-E2E-Test-Auth-Token") -DEFAULT_FROM_EMAIL = os.environ.get("SENDER_EMAIL", "noreply@bullet-train.io") +DEFAULT_FROM_EMAIL = env("SENDER_EMAIL", default="noreply@bullet-train.io") EMAIL_CONFIGURATION = { # Invitations with name is anticipated to take two arguments. The persons name and the # organisation name they are invited to. @@ -259,8 +257,8 @@ "INVITE_FROM_EMAIL": DEFAULT_FROM_EMAIL, } -AWS_SES_REGION_NAME = os.environ.get("AWS_SES_REGION_NAME") -AWS_SES_REGION_ENDPOINT = os.environ.get("AWS_SES_REGION_ENDPOINT") +AWS_SES_REGION_NAME = env("AWS_SES_REGION_NAME", default=None) +AWS_SES_REGION_ENDPOINT = env("AWS_SES_REGION_ENDPOINT", default=None) # Used on init to create admin user for the site, update accordingly before hitting /auth/init ALLOW_ADMIN_INITIATION_VIA_URL = True @@ -275,8 +273,8 @@ ACCOUNT_EMAIL_VERIFICATION = "none" # TODO: configure email verification # SendGrid -EMAIL_BACKEND = os.environ.get("EMAIL_BACKEND", "sgbackend.SendGridBackend") -SENDGRID_API_KEY = os.environ.get("SENDGRID_API_KEY") +EMAIL_BACKEND = env("EMAIL_BACKEND", default="sgbackend.SendGridBackend") +SENDGRID_API_KEY = env("SENDGRID_API_KEY", default=None) if EMAIL_BACKEND == "sgbackend.SendGridBackend" and not SENDGRID_API_KEY: warnings.warn( "`SENDGRID_API_KEY` has not been configured. You will not receive emails." @@ -299,9 +297,9 @@ # Chargebee -ENABLE_CHARGEBEE = os.environ.get("ENABLE_CHARGEBEE", False) -CHARGEBEE_API_KEY = os.environ.get("CHARGEBEE_API_KEY") -CHARGEBEE_SITE = os.environ.get("CHARGEBEE_SITE") +ENABLE_CHARGEBEE = env.bool("ENABLE_CHARGEBEE", default=False) +CHARGEBEE_API_KEY = env("CHARGEBEE_API_KEY", default=None) +CHARGEBEE_SITE = env("CHARGEBEE_SITE", default=None) LOGGING = { @@ -326,7 +324,7 @@ }, } -CACHE_FLAGS_SECONDS = int(os.environ.get("CACHE_FLAGS_SECONDS", 0)) +CACHE_FLAGS_SECONDS = env.int("CACHE_FLAGS_SECONDS", default=0) FLAGS_CACHE_LOCATION = "environment-flags" ENVIRONMENT_CACHE_LOCATION = "environment-objects" @@ -352,7 +350,7 @@ }, } -LOG_LEVEL = env.str("LOG_LEVEL", "WARNING") +LOG_LEVEL = env.str("LOG_LEVEL", default="WARNING") TRENCH_AUTH = { "FROM_EMAIL": DEFAULT_FROM_EMAIL, @@ -404,8 +402,8 @@ } # Github OAuth credentials -GITHUB_CLIENT_ID = env.str("GITHUB_CLIENT_ID", "") -GITHUB_CLIENT_SECRET = env.str("GITHUB_CLIENT_SECRET", "") +GITHUB_CLIENT_ID = env.str("GITHUB_CLIENT_ID", default="") +GITHUB_CLIENT_SECRET = env.str("GITHUB_CLIENT_SECRET", default="") # Django Axes settings AXES_COOLOFF_TIME = timedelta(minutes=env.int("AXES_COOLOFF_TIME", 15)) From 303996ca5f87737d850800a1a7081a252f8c94ff Mon Sep 17 00:00:00 2001 From: Ben Rometsch Date: Mon, 23 Nov 2020 17:11:32 +0000 Subject: [PATCH 29/47] Refactored Dockerfiles Rebrand the root api response! --- docker-compose.dev.yml | 5 ++--- docker/Dockerfile | 23 ++++++++--------------- docker/Dockerfile.dev | 13 +++++++++++++ {bin => docker/bin}/docker | 0 {bin => docker/bin}/docker-dev | 0 readme.md | 8 ++++++++ src/api/urls/v1.py | 2 +- src/app/urls.py | 2 +- 8 files changed, 33 insertions(+), 20 deletions(-) create mode 100644 docker/Dockerfile.dev rename {bin => docker/bin}/docker (100%) rename {bin => docker/bin}/docker-dev (100%) diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 9b1db22f19e4..077a0388b2f5 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -12,9 +12,8 @@ services: api: build: context: . - dockerfile: Dockerfile.dev - command: ./bin/docker-dev - # command: sleep 9999 + dockerfile: docker/Dockerfile.dev + command: docker/bin/docker-dev volumes: - .:/app environment: diff --git a/docker/Dockerfile b/docker/Dockerfile index bbcb693ff994..b9c659a708a6 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,20 +1,13 @@ -FROM python:3.7 +FROM python:3.8-slim as application -RUN rm /var/lib/dpkg/info/format -RUN printf "1\n" > /var/lib/dpkg/info/format -RUN dpkg --configure -a -RUN apt-get clean && apt-get update \ - && apt-get install -y --no-install-recommends \ - postgresql-client \ - && rm -rf /var/lib/apt/lists/* +WORKDIR /app +COPY requirements.txt /app/ +COPY src/ /app/src/ +COPY docker/bin/ /app/bin/ -RUN pip install pipenv - -WORKDIR /usr/src -COPY src/ ./ -COPY Pipfile* ./ -RUN pipenv install --deploy +RUN pip install -r requirements.txt ENV DJANGO_SETTINGS_MODULE=app.settings.master-docker - EXPOSE 8000 + +CMD ["./bin/docker"] diff --git a/docker/Dockerfile.dev b/docker/Dockerfile.dev new file mode 100644 index 000000000000..a1ce10ec9226 --- /dev/null +++ b/docker/Dockerfile.dev @@ -0,0 +1,13 @@ +FROM python:3.8 +ENV PYTHONUNBUFFERED 1 + +RUN mkdir /app +WORKDIR /app +COPY requirements.txt requirements-dev.txt /app/ +COPY src/ /app/src/ +COPY docker/bin/ /app/bin/ + +RUN pip install -r requirements-dev.txt + +ENV DJANGO_SETTINGS_MODULE=app.settings.master-docker +EXPOSE 8000 diff --git a/bin/docker b/docker/bin/docker similarity index 100% rename from bin/docker rename to docker/bin/docker diff --git a/bin/docker-dev b/docker/bin/docker-dev similarity index 100% rename from bin/docker-dev rename to docker/bin/docker-dev diff --git a/readme.md b/readme.md index 5326d89dfa36..bacd4e8e219d 100644 --- a/readme.md +++ b/readme.md @@ -120,6 +120,14 @@ docker-compose up This will use some default settings created in the `docker-compose.yml` file located in the root of the project. These should be changed before using in any production environments. +You can work on the project itself using Docker: + +```bash +docker-compose -f docker-compose.dev.yml up +``` + +This gets an environment up and running along with Postgres and enables hot reloading etc. + ### Environment Variables The application relies on the following environment variables to run: diff --git a/src/api/urls/v1.py b/src/api/urls/v1.py index 6d612e2d391e..cc7246d21b7d 100644 --- a/src/api/urls/v1.py +++ b/src/api/urls/v1.py @@ -13,7 +13,7 @@ schema_view = get_schema_view( openapi.Info( - title="Bullet Train API", + title="Flagsmith API", default_version="v1", description="", license=openapi.License(name="BSD License"), diff --git a/src/app/urls.py b/src/app/urls.py index 670aed611130..ecc634d0393c 100644 --- a/src/app/urls.py +++ b/src/app/urls.py @@ -11,7 +11,7 @@ url(r"^admin/", admin.site.urls), url(r"^health", include("health_check.urls", namespace="health")), url(r"^sales-dashboard/", include("sales_dashboard.urls")), - url(r"", lambda r: HttpResponse("Bullet Train API")), + url(r"", lambda r: HttpResponse("Flagsmith API")), # this url is used to generate email content for the password reset workflow url( r"^password-reset/confirm/(?P[0-9A-Za-z_\-]+)/(?P[0-9A-Za-z]{1," From 3a1340dba12845b60e4db12c9ee8f69938a387d8 Mon Sep 17 00:00:00 2001 From: Ben Rometsch Date: Mon, 23 Nov 2020 17:50:27 +0000 Subject: [PATCH 30/47] Fixed requirements Fixed test to require chargebee --- hero.png | Bin 71475 -> 37372 bytes readme.md | 30 +++++++++++++++++++++++------- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/hero.png b/hero.png index 04109656c771f236fb26d8ac01156528bd9c6550..78beaa7a07027432d6bb88abea6f171d4964196a 100644 GIT binary patch literal 37372 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 literal 71475 zcmeFZc{r2}|2E#PQkDwYi$d1QG9h#)ON10A%Wy}?Hul}nW=WVNl(n+OFcV`Ldn&uJ zHU=~H-C&GmEMvxdbwBU(e7`)u=Xmeu_jmuyF^8GUbKbdzWu95j{tvB4Zg9sZ{N9nw{QJnh;Kf!(0FI=m-A;&B*X-IhP?XJE8x=3dP7LBX*>; z>eSPJ-`;whY((d)nAyJ@Qcw0h?d1Rt8AO+P{{2Aw&nbWDEa&qnUw$L%chN+K>IsoB|9W!h95}z)ke}+j;op2I=@wXz5}< zLGiyZz#|}}MV=?e{Qb~*3;_=5RS8rU{rd(;l01j$f8^G~{`X_1Kms_V!rpcIzc4`D z;X|P06Hul9B4)gSL(JFb7gGKU0~7%n5Lc(C_5Tc&e|h_N()>=E-%0a3X?}N_-<{@n zr}^9M`8PcK4bOhVv)?n#@0sTJO!J!v_}lEsZzA9~5%8Nm`^}#HX3u_8&c7+=-<0#; z=IMXyH2>c^%~QtFi8oDp16P-oT}~JF_eB^k#6~$Hwsn{{BWxuys{BlUyqS!Zw&a-% z=e$)>fmV~9)BR84K-4}!K4Eu@DBbH7xj)XOzuML(XhNr5#A~g?Z^!Z)M|dP@N(;5b z!bD=T9WJmtsS4ASUV7MHY4KI#n{5mHz@BgX z*>rS5L6B=RS6Q>DaiU4DU$vWhQ$F*(=E6$B;D zzh95dKV?5TyEQMVNht$sP-2+EUPjseM>+T4`EwXWw#bIPc^y`ABX~KpiCk-bfoAaX z(Mq9dMNvh%PLoFgsY3$)alLlT4!_jDkvdJksKY9Gwl|p5;6M53r_4EwX>EMM9_i`p z(Nmwpdf7$OVlOtNP>N$ffJU;5Hl zPrxGqpuJW~VApPliuv}(ec|_|t`j0Ig_gEhc`F6gS zY)DwgabLZ=-#ex*C1#8>g+wl&eq}a#Yd-d0KY90?;$lEQ(-o}6?pBbW#!9><1X)b5 z3`!jDp}zFm#xz}IpOh;IRj8y6a`alr6!*su<-bsjbNq}>(cBstH>{!8hz+l3zfFID z5t-=cwUK`L?HH5RXdm6_VW(NR*@NO!LNMot`CDD-0ldu}|G`@Oo<5WY0;-L*b9v7< zo|YW)C>NWr)}4qUioOBzL^{zcT)x#IpKEBodd$vDT!Tn=dkd{q)A1y~@~Y1Zc83Sx zC4~B+2=B(vp`bXq=rJp3WP37s2G6Io`?vlt$Ecg%aPxee4#(})M!kJTV;ma^pG~#x3}=wR?>v1K~$-4O4XwG zJOp1VdONe;`Ypr5^Gr?$!u3YMo|yaBRXlwt3YcI%rtR5Y6GYw!Jb#?&PBgI?CR9f9 zVJ~Tkk2i0Ri^zXwOZ9@lhs^7-%XGMgLxYN863jQVl?FETR2P+-lahJ)DAA=?&J;0b zXEdH|mv`bZ}T%ePp{avx`Y@x-l`oK$&(Y z{Q+NnNLy4lgjK;7BC_WU|C6do!hq_`XOI5pHT~0KbhcmFxqUuRbM!M^sB|z-sXOWI zR1!*Jn4C3cS9sQ)MmMZ-fWBP}c&wMz9-NQrLb}Mj=iC&gn%NKHj(@~OCgT>2zkkj; z{3;`8ZC-3>^Q)$*Q$}D<+V0%=pY!&8c`}j>da>u@vq=YT-wJM-Xc8=O{Z-~N?>-iBh7Mo*3Fp8@=p3 znVT_YS5YC`f?kXni}uV2+Obh@EiPco7~1%z?UQC?iuVM3;f%hgU2OIgz5Vlz7zAd1 zX8O1rOTodCq?)5a$cK-rJbQCZ*a}FoiS$DL-96>$N14~H z8V}PULcTwPV+ny}+HsE8WSM2NZ&OVP-#lL^;87UU;1P*Ovq4Uq>4|^Dd5y{%Ryg{cA-jccy;iYeCK-O7Cq14 z&8@d-4~&kvB}}QvJZ9q=xtCDou%I*P(e_RSgsga|*;YBtz)%}z3q8EG zdZq*~ojwMQc^k(!M8hnvtHtn_672k3tq&r0Hn?g}*D}6m3HyX#iSI`2WJ|CbyF0LE zDNq5LRIMua?AE9od?;WGJ(#1(n4t@2-$6n`ERiqR@GKYYn8^h|C6?9#D)AZDcc-)Z zSA=!4K9&s!fQ_28MkA^-+SDY3h zC)c>68lELT|EP4e@eGmg{j?)^k0)%79)&_rczf;%JI*srw+*3v53$ePbv39s?mETaf zz#gYBPSIcr_FFoRa&@|UvM0ljR8_}x_62vClbdWt)3;>TfR09+h7@I{KkOczxP(!> z+J1!)Ix^L%qFaZ3s>}<#pyf{O?mmybfg#BxYK7k?%GKmGT(HU`fjS<8sBN}n?BDp8PVM#1Mki-kRQydJR=(QQixEIyLc$O?Y{opVte9`ncl!e$2%vmW}vHwI2|M1lf#4jwq z#rx;Z9^bJZC@*LPe@kjMWV%)4ZvydHaQf=W-f`T;M07TH%r5@{$1K(}!*3y9jP>T1 z%VpSv*kM?>wgNA2Ge%En`F3VUe$?`Ic!pm`2779~>d|~cmRp}?7q!JEpqd4y_p-}u zXZkbdeW*fQ(8Gl`yY&|im!dQK`i=97k5o4J>M3@3;~&&a$IfhHjp;*9Xr{A^b=Wr2 zdI%#hXu@;YSz++YI|Db*2YmM-ug(zjUpZvRog(sG^Z9Rl^;6<*lNO;aP}5`nY3zKV zO%@pMGT3iBH2K+b1!7Q-)v=^3GG%xz%!{>GNql+-*{|wm09|p9CHN+a@qKan!I^A~ zbT(-1&vdv@($Rtk%7CB++vM!}Ey|DZ>Ww%iY0OTmj52h}!+sJ#%9K`(O3Ek?(A|@H+f#k0rB{ zk8$7poP||AwtFm#9W*3RkFeLQSbD#|#6ZCTzCBcVE4UUSoO=EIay*-Dx!IEBddRD)E3&900spery}i&Vc*ka*lLzYNLkSGt)Tuga>z zjgY|v-M}Va>C-+2wG5h78-Je82#{?G!|?$%WDcW%c~FP_7?|)Z`Jk@?@M7<9VRxjO4+wq+7PVETy#>FK&2nR@aQhR!ccM@T* zF}<*^`L^|-y=9Zu!?~DPwLi-u2Fu_V(ye5J%Pk<&0q3zL5>S&6OP9m~#CF^A%*H~= zFOc5JqqRfe6&eiaNt1x8q9r2}4d4%oCA+N)mn9-6b>h3`*Kbd**GC*1&sZn`W{EgM z-jvE9vQ2Dz$OVsaO|7fJJ=&tt@V-pgb662tB5l1)Oy)sfXHD46@z_&D$#@<2)#jYW zIA$)^!@cLxN9wTWJ z=DG3b1dzWm+p?wVyFa*Fb^}1+V%NzZv{?zF9t)c-CDZ+D(<-3~63=^&yZG#6doTqc z%|@4VH4JM4e9oZK`DeR1yip(VVYhDq1W(4o*FoWa-{}Y{)%huDyQMc75rkkwCihp} zPSV_LzKCmiI~Yjv*63n{offVghN_ql?@X(*AwtSWbrQ>6O7mrkH~YTLY>+1{*di1drUZjg3t`sJYYu_HzFt2FY8(Cg3GJV$rG8x$tpMQbDC9p>7kg*m(<#gR>-~)b zcz3*^yX>r_MgWQ5u*$~=n4jEktfwRw+fJ}1@svN$zEw2aH>cE7%p+a^GZC53o9Pda zOolK^TPlN;Tq`mb*SJ`ccsB9ZQH_zwlOBpnHI!EE#DaPEs<4~M({x2^V=dd13iaef zJ@{C`%G^-vU8lapk*St=g!S;arR<1#zt31NCEb1}kS};C7{)_O;>~U}Z{PMx--`LE z|H~$i0GoJT)*qeu&JvX#yz}bhNfoE|D!(;?8u2f@{%u8DTXBWPu%B>~`@Sxk!p z<Y7}{9xj8K4pt%0KocM6IOn1D3Zjc50p(-1u93;PvH3H#iL7>UnaLpf zQQ3kp7O6=d9asqiU}U6W1ua&r19t9>RD%X|jv9NX?~~cxVw3mi%6s-MqKNiNxo(Zt zxgjtC5flCGd|)p!o?Kz6YI8@}#J6ECe{O0{{%Lg`i%WuQ8dp~GVpK8uL24P|jpLv) z56{VPGdJ1$+#tKSjaHeC=!MevotL|tU4uf1{vGs_>%6)Ahd^5G&Bvk0UjpYrdwH`-u*iM_u6Dti49Sx~42BI=w@Fr++Sg+-q>o|{ z6N#utR;vIQEm*m$M>MF4-9^5*8yLa zZ3SU^OEai1w_^jr;;u+5ceEdAu*)AeA4XN$DvlG%)<>Eww^d1S_s`3|1W;l=qr(bZ zqc*34ihA@)4|L5Z36hfvIh@`KZfHh^8t7)tnSnMg#CtBXeDq28pJWeOinEG-B~zLAQB}CS@K+W={(odqpZ0;~ z8LAjxq16v}CavHW7MxCee;f>%I*0o<=B*LYlau_b!>{@WMeuq@MZkSlwsid1Hd|T{x1LT#EQli zo++OJk%3`Kk-e3!rE++;l&{8wXRO-emLrV?FI082bZmdl6=ubE+L7_8sADd6?p^s- zNQql!yg7(d_7_i>iEApY49!$6QnkU}a>aN%d4aq-tn<#!dYo&+Q`%xbTYY2gM(W0z zp!7mX2UOW0=9sw)g}6|`a7N**f$6};n{$Kzt37Ja_azp@kME2C-Ki~Dq(&bB2?nfE zt}))q&IyFj!vg0^J-0WxhFw4JbjWPo52BKG2Jfanb-bQeEtS1k>Nzj(jqh`6X)l~! ztTYO)oy8Om-j_755#p3KJ7u6+EygKjdCEY_f)B1j@giQe(I+Z7LA~rkd{2ycE(Hf_ z1QO5(TYFdtYN~Dxyu3-XAt4kRX1j$qU?Hv=-%nFXNy}hSJ{L@-%g7f`y!Tv;!dKCS zPp+BPQZft5q98uRn#C(q*jhDCDN{2r?WQUhZLvI%&!@A-z3&g&4|XOp*LXuru8~{t zc%UjLsH3VH>7Ir#q)xAN9$bIv5I)aK&`)BsQRu(W5XH@JqY00>Vx#bKzT@MR{_!@gE+)l zo|&XE&$3W(dI;e%UM>z*Efmwiw?%g2WIt6s`ttx=bGFSVwPMrfyD6 ztDefKTK!>3C>R{UfNP`#g6Cg`ZLRihJoBtM)5D_j;g=tR)*WO}hsx*t4}k(rl*T^L z$9{rS&&UKbu5=Ccny}i!U5cI%rsR@uctc(MUt-7Vc}&yw2&NWd?!t)^QgAyEV8Dd45^q z7u>Sg9v?_1rGcET1*6?5b8%K|?~OT+U~hh)1I{JaVJ3Nm6+cMBlrD_74|#`eqRKU$zfr(n8)7pYJgq{B>{07;B24r9Yo|~e?u$Rg)@(z% zySli!=oDA?_8o?}Ku8fI$hfPVdUK6btel1Xre z9CNV4JD>yy@|{s-k;(l0?6OlDlooEoij^9jtj1uq1sB6>ZjOK!z&mTHtntR!TO2%h zL(|026FY5&f4hFqT~;bVXffbgr0im3H<2b(graR+Fn8h17^04~*iJ#tcQ2Cag{{{@ zL*UELG)}_PoXbs%(>Igmf-BQX#M4cr^+RH4?SUs;e2j^bi3)euRsk^-&=^cm?x?}L zI}>9MV09eyt1f2|F)Zqen9bafK>MnN=$xO0ujKXgckl-FB_QlfiGkI#=CY%mC1KLGmL1jmnzxS*CU^wAqH)9HI8F~jfidH0}zjg85|&)n2umNG3>&iqFl(G@$PrBJa;#FVQVe@&$ zldS?hf+nhS)_L-rFJRvHSB<`n8`BWw<8TSJ2fHp{OViR(X4Oi;fs{##8rQb8fZs-8 zK*dj-9p(_oD(G7m<)d$H`8`w5b7>blBwxW{qvn*d5KA?-@7&M#;QR6h4 z{dDxgU*cW5uiJ#Bo8J$UaYWYHp8O&Rl4dra_AMFbW?9khGHYsi1?Kn1TU3fb7RgSl z!g^nx)Rc!|pVM>`e|g9R0OhabxfHy%6M@8JnmC|iBa@pCGJgX2Br$A-L^sDlPg{E$ z2@c=Zj}yY?nVTe(X5akN#NPwQDl@x+=wfs`;O0KXKX`mGue`lv&B27-vwG`2uG7xv z4bD41H^LNwc^`dWTvgd9IXZr#H>lR+6I%Jj0K~M!qyl|P2!a}$O1ha^xXfF-xKhKE zF@Bc?8a!%4hKKmC>GBRxAIfYcKEQ0ex1_CJ!~hfxD*uWRN@o~`=O6dnVWfp2lBqT0 z_GL{6T-3^!SSi&z<(Ex-eH5<0h)iCKJ-Hs4T)BvW1x@P;B9w0t&D~@xr?yw|wLO^?B%acD7nOy-i!*^K?s!+^S9_F z;!rz(td6#<$6!3D5+4g;t_^;S?ycdeky-3)Yl0h!J z%YCOQ%`z|$vc^3?z2`Wzw^2Ujc{1wS?8Zh*j_TPA%@TZBe?;G1H(6e?jeU)Zh+bdb zBIQWMXjR}MTvm+|kC|qKZ^^F$VUy<9HcA=k|#a}y@`=2R7bQ)a0{zgin;K&KIs099js_b{af9?Wo&gXr20L&AW zf>FzAIa-u#g7CuQs+NJRJAchzfL5Zo^|i?bZ7X%CJln&zsJCPq=hTH}SFiv?hKj4* z^DR_>D_uSaXKimT=CEE>w&J1VkJgf2MSFj9+^m2q2zTXaJO3xWTGE@xvXO#8T9}o*QJXS|$a>AvAC897Ih1O_9ibZ9II}PfrUFNF zlMExzBEea;Q?@b@DA<(xnu|w)iOOLRE72alvE4W<7-%q9FiJU+p{`|bA2c#<^|BRJ z@v-AOD{f>eaA@|Jq?rZzCzOyFg;4`09*8EgIsoxcl(~M0g_1dsoj+BpJK-z zfujYh>?exml9=XWIkfX3;Wg05=*7rn-2|=83RA<1u_-b8y4qmX<~}_kH5Wia^}g}r zXR3I{WVc7jvqzKQwK?iMNoTTYKz@QZAJgB(J=wp0YjaBZli&0~l7;C|$0W6{nm*wW zOR%4wFIo}+{4{l)jX!bl>bd(|EJMP_bNE*B0fK~^h}21mUunyGth(iu30L8tY~<8T z3YF)Qj1ns-{|39kiA#VRx&}O)5OKDwY=8{{%oE@2CwuF)4rIiPBump9a?^BP%jT^|BQf-*md0bOU%T>Ba_uHV*HyJf%X2wn?X_B=-UwT_dh`u zTVdwoFnib)lYFy55};>PpEBk-(Ks^23|GF|zBwnlWkmO;k4*i9_(s-lFQ;sMN$PXN z&EeeVDjCAi%JD^2tkRk1$$|mv4iuDq%0Lc%wdDSk-;dj_k+u>hRPUqo-E-!R)?YOH zw#2^O6h+vx8_=${bYCH$eHvt2<9qf%V8Qu+0^4C_7+Tiut1CLSD?=EyP ziiCx0eL1fcgegeiL-F6T45hWq#2e?*x8=KS4|3fS<14i#IiC`&d5s|sG~YPBONGWEsk ztdBo;EcRFmOe(GWFVs&rDx5Au25!f?8E<`Ln&+PmAu~S@PIVvrToUFx1hcBzgbBhm zbQLUI`iy7Snpw&a-M#}437Cm*uVRY0Fb6zm@lt zzS9Qs*GYGop*5Q&qK!MsB-lx^%{ir@g}~{0*5H7y8z*c}?B-57-vCUZl>fYf-Y%D^ z-~8|z{_SI+*5anttqL+U)=0<5M8e4gg|!sb0|P2a(kc-2swLNSz(YLs5;W94dNS2llY<+EgeI_=1O;o4fdqEd`;>RfxuW;fk zDwv=6CpA zGbUC|HOx{P(_y07^F=nWWYR}tgE}l1U*N6nk>vE*o_F>4E~S}PNT&JQnO;Xb`(Hev z`Vb*y^R3tXR-n0gdV@vfeNH3QO}(9grEBV!F#f&$I~*Y!{l1@Glef6!PrBw<@uyj0 zuEi*A7K^cX*)9ba=-BV}(d8hZ%!PNDnqJz4CZgD$5=+gsMh|P<3i$RhmbdKoddb+N zT&~f8q*gh=VKb-2tTMDdr5NK=WJ3z(Qs_U#Co<^OsOSm*aLuFyp+pfwecjlfz;;TM zCD6T=@9PRdlTOz=*=s3QZdJZCfPUL(B9}Rxp$=Z`F3gaKSBUelJ>8LqF%4LHIfI#L z5kW%74RFEBv9Sav4>$)%Vsxb4I$w$0t)0kXakyL4SLNrkWO4{C_ZOm7e3O62A=SXd zWn~lPujM7@wiW~=i#U`TeD0rA0=atBOe#3ID}4-YwL=DmxSxK@K5=8d(;*V2eL-kd z_}a*Kczv6q?=Owu$1_o`hByPMDC@!p+^%CU3dS@V1-V7}JRj8N`CGcg0!+uKrDGuaDVuud$Mbmc)aQ)iN#WXs-q`ue=RF86{LMb6h5DVnycdftXl=;-WI*Fo zlQ`c#iTE^bpXmDY_`BzeJw1n9LeJ7mxip*=kS6|~zxomF-O##UK4Vax*Vey3Q~f-q zd^6i-{0WR2#ag>w+=r@j3PGzWqm;*oe~HVySLb!zfJ`2G2{Nh2T1dZq#hB)GAE^nX z)-7ZUW{yI*R7qUQnIvDHIe3j@2`BlTyJ6i_c)yyPTIR}!|I3k@_t%;z95}Pfp(i+=ZpRRA zF@Ut>^VQC@(ews)w>dgx8!oc68jHJ7f!P?gMA6;}_B?}n&pjJCUDHsi7HH7m%HxtA zXmBA;)I>eg;hrxbqiS8T?j1C&@i94LKg@3^xn_TfcQN_aM5%yaIlqXeG_#`}__YC_ z;B9HHE{?Ba_@Gv&Z%KeaWiiOkU%RaZtYcpHBk_L|o)(FLQ+q7gVkatw8|T($9kTKs zQTsPZ5*e#YalE!%uw&4KRtMr$PyJfFx!4)&M*h<1f-=70>B21@$Ju{)6YgMRObl9| zMZH2Z)(Wa++^RX5xujnD!Y1a!p4awY1}L(~UkfdMjFoKp>*QrXSqM!3MZWnCCsr@{ zQx;?xT7wJduyp!>B4uURqWf<8Xty86h-iWFyX~x4EtmUfR7_^#O z1o1sjhO1GGh}Y<)?>rk-C?mpfAU|ILjNthg#qF=xwnymVR_kl!-n~RWy0Y2;esOE} z!spY3Uk^!|9WV3W0MML^X3qTS#j_hYlLn*qNMhtx)NXe||=c zUs=f`PE-TB{G-MG&XAJF!h)fGYYB)2GsXn;3v^J2|x1EElUj}fCfhtV_taJZAE9GC@*DTWd^P6#QFCN zAGHMG;|7Wu2HB=L(d%QW z7Bc>^YbAJ-wy_<;_`Fzt7=+K_k*>oAuwG;Kv-BR6a<9jM>S3&^f3do0W)Ftd{QOp& ztT=bb`RhIgYn8glh>o3UR-5K-+ckYFaAimE#Gv~YucaZFfYbzaygQv!Q_1*BaXSue zPX(zlNK9(*S}V^U&!qV;3qLl9TEo0RQTJ1aLZV+~-J9R3!UOYYyXqQ#?^3|8hXF~X z*HrIXI{fN0VX?*-$9<%v9ogq@gps$FBEYg;bLaF%*KpdxUmH9RhhWvEYQFZqXWFo;S zK2uMOgxW+@SNL6{SZhdnCksAipk)F48RfU#oI{!u#gt3;02>HHex&)0Sq-knNnlYk z@}$M3no`X`^Yh5+jht9x4?0j>`SHV7E0Ml=U7G#Z!E{R1yDvCSZ(k`uuOE4df^&E? zzQ+N&_3%>*MXktQsqysTQ!b!L4C4hiVjDSsfZ8`5EVj!eNL#}nfHF^p&5r*n z&g&HoX*^X+CFICJ!Jte7nD?G%nic$d0#!?nD|y$@cG>NKOX%aB8FL}UM;$HiaIfv8 zTMUr^Kr<=0S{|VJR=W0z4y)==PRYe&KwjKmspZir5KHi{6od2gyOV_t8Pf&vk?(i| ziq;xqY2c()BY=O57cLL>IU`dp(z4D%4Ecbv@UQ7n0QgOptWS3Hmp^*A_+A_^Yv*(N z*k*QDR^o^$#A=ENaGhBM!Kn7yVV>FLx?Hw_obQCDRFBgN@X{X32jv1VdStTc<cb8|j5P3`)F?mTOm{nY(WqgH~H_yP@rCfb}?HP>;L&V0s8ohE}xW49pKp zQ00?#O~>C&AL2=0!5RFxEh@*^wpF)T8-s%FY!8JYw=LW}A=#q>#46el(^#Cx}meO!%cO1QG=`UN)LdRdUzLe_BLb!%W`R$^W(7Xg*Z61ti$4P~)T%B(#%{m&V~|y2 z8kimYKOkPPG?^Dum>fbFb+O8BW%F-V=K|v{494G)CATL7B=&%!9%RPt7}Upy6QJE8 ze8ZbvhktF71@j(*QtwX8*02r+UC+z`g(e&5ehT0Cz-hFY0k%x4Q_O897=ZKkpn>zl6xpuhI znQMFM%|;u(Jr~gduv3i9WyIL?^sRumv6oRm5GW>{ zWmlry_z%m5Gi5gj%sCp%LwMtc+2bx$6L)l#m+b_yLOwFlQZTDAEidF`xy6JpL3J6p zi{JvY<6@D^+Q6DlXoutBmNoU{_&PM3-J4sRGI)(?nH_5W=JI{t$Zc@@JcF^t0cus- zRQF<()HoUqJ_p3e;b7|tpMGiU#08;dRnaI^zKoPTzCNdAAe^W^1m1OLG5 z+kL0K*BvrP`|Yb`hE@mScxO)uZu|g`btGC1Itc>Kc^PKohR(=9dkoPF&JszjS0he( z+&(3{<8BzmkG?&KeiY(cyVhoa#{~m?i~W#~eaaRR{R#bZJ+E&e^d>Q=U&fl)cg5S( zOMa_;XIrR-{yiP^?|Vw;0DZ-LkG`^X>GGUt`YW&P?7u`oTtl|Nzm^2697vjH9W1Wk?Y?qMTdee)S#ns37Zj>?qPI@(MpKIPW;aafh z?4K#uqztFY5 zK-bq@u@lwpw|SEkT-tBhchNrzfY(L_=VaLOSqc-!Hr*4 zGL4C=&cwX8jbY(N9&e>Cf(!C$9FsIJVjbfrBH*oT-Ev_|O%LB5kl(R4JKIT$c>p~3 zvgUhtcIvWo|Hbtw$nrdq%qkd0+;{TMQ;eqknGoAoA2>kK9XUa?HZ5J+axEl0t1n46kXrxX)7tx@9K^`rOQnpr70uRhY%=Xtbw z>B8OQ7~g&~gNBAtqj6s4he>Jvn-GF$+Tg|=y|cC`kEyZY9iuW@T9Ha3j^!i?*NsoF zdRS845A7uidJms|u1<9b(cnE9!l1lfxT@f1H&e-@?4DGhdN_-6cseBuJ{(-v>ycRS zAXnd4)f!-x7-Id!uAP1*zozS_Lqm=$0$MPakXSsHj+=7}6x3bF zxPmF)895K>Eze=$+v?~R@NishU^bW;Xf-GBOg}G-TOH%ga9;a(<-pN%Pxk%q7dGMq z_l@4w4@Y<2jTsg~Kxjn%Rbq|D_?yobEaO3Q+bQG+9Pf8=2{e?Iq}T{KtfFCu|9 z^kAwNmq-N#`z7VlR35%?Of$sY)U}EmzkS=0SmD{^gwDlqYnye7Lu+=LZ&j~OJ#OvL zkOLLdyq!o?h~wbL@GL0}8@H9w{W2ty>$Is^$Y;%JXsscPT&rS)e$oL?zNU9De1Nr# zko2NquPc6h*av9bOLzNE{ci`Idvb$u>;kb@f6njr*B1c45)N;cb&R|hyd^w=>u&eR zns7D)yW8UuD-HF^BQGY6Hb&u%Y4>6^k9TtotwKVesIlzt=d*&Z-p}>qWXe|@?WbjG zme2)d*I%2s_t57=m!X3fwNmpudX*q~#>RPObtj^DIOKXz@*swsmBV>GtuUt!82(do z`iaW1Z0>+TjP6jha@&_|`)SoYhcL?L$~@zH7I8`U;ypb0^xg#aD`iPqas9~+9yh9o zggoVA(Hijd8>{fLX~{H{omBL?5Y@lnd#R~T&!$W(MxY0cis}53i*|8QO&Jm40I8pz z1~{RY8J33V2Kz8eqcBcQT-91~S^u0(8ReCM+Gk#63kL+DWN;B*>!dKz2do1=y?!`% zYznDDdlW?1*127{W-(zrKBn>N9p0OFCtlK!xNZ5!D4rNH@|s0}1!C;6N_zgY!kD?W zRklVSRE~L-MeX7wZU;`9mDH~Nq3cxEJ+rJ(usYOAFOXX+dw-p-tfKJvU4g2c=AJCs zw_hB(d}2>JoqH|u^hX_QlX-1eNK_ODh~LIu%wJ~-@rK`By1YGKdfIet^0Ar0P##D0 zBa7_g;V~yh+rD;Yv(L%heD~0p$j5sDvk6tpy{EXNdewyJ6;NHXl!iYaj(3^TQn5ts z1bEt;h3Z*#%3kFF4Qjz#9bU-dT@!tKSjs_*uh-Aw4rKfOqniN`M_2+JFL zRASHHx8Kft8v;r_J9PfyP>+7hm_{31v5k%L5CVjNb%ysv5IvJ%W< zZO|(!w$CuUR;MQp4Vx+eBPwb(R~I@@$`+qhewYW;Jr#4<7(}{Y<6+U5g>)-rQ$fBzdsx?tarE42D?FD~M@u0q2k4;x@q6`H`>>jf!qYi~01+YFwe!;Y z(Y!x#<@TUne=JX)23k|x?7|ho=pYo%BA}EHV`6da7^0g|@j6mlu<~HqxD3C_B2BRl z>ee%JzZU1MbDNg-u4m|z6lSxn(^uYStXP{rJV`ze^-{3L(Wwe$*NziDXjO;{|F=v# zcf&2tey=;9E*-YeuMeSq;zMGdT#^7;v%clhz2)NJ+9%!7Ud=d=F)GNWBFlVX2&W&@ z)1ssKSoWfEO!4c;^(8}oL3!CPb5n<-&kQfO0d1{%hrfriUfawy;`S_A01mFD7YcnM zNthK!%>BHsu5s%0Hy-?h9{y??snKfZ%TKj`q*%`sxE`B?%X>`y5&Mzr?Z;4dP1?*# zZt2#G9*oIaUIlfzx3nT=+wW6YRs4$lfD!S;1u1O~5N%a3FBd%p_|AiX>E$-YRjXg?0XL*o+)-q7I2%g#jP z1{B=vZL1qH(+;XZJMH6p*22B#aY#^`r2o2L zyXOMnmBtu;aE3yvaE(Gv&&Me}o|$$izjuB8G(ljeb5yuuE1Onqvp)11Aj6J`4iveB z4|r8cWLo*0T$aH2*Og;eDdrvF)$NZ-8`zZ4xj}LkU!sPa?CbCmyQRi@w{mg}FK#X+ z+i>k2*Uyn`oVR(sYQeq3QYlIej-F`#Iz4lXe=A#GWq{xh-@m)#?|XXr@ED;iHlxwo zG$1D&#NT{rxq%nA+1CBGhE(4p?!Z$bU%mXo*u5utF2NIL=bwOlOY6Wb&#Q@L9I$Qx zf0F|1f{Tky-0$DpPLydKjR*pk5DclFB&a{$CDtBm;@|6!~8apq%2fD`+FSdGnqC`C{q`+BB%V#nX-Lw;a zruS=86LSnQ3`42SOk^WMuhfcHnj4cVu_A6KZ!M+aXb$jbfHc?|p{Q0}+NF)RWA0M| zR=u>f@9SJe0Sk}5+Y0Hu=h|g(mifhO{@ty4-+|!$&yug7*xSlqTzcKPu8?c|XZ`2Z z&TN^?*|%CXP8~h!$-hd)yvjLp5za#fa&bLU{=9@ygKzDCN4=g6?b!2x+tH+ri{FqK z|BcVZKCg&Q08}q+s)jl>m4r1LlwCe_ieM!+Z7Tm9xF6s>jpP{g&s~6>oO_A+ENA6M z2NFDb5j@3Xb}dzE@fC7kU}trY_g^%7p4{+_VCj`M6zKnCVQK21LG@?uVfyRwq3_3_ z9>xJpjkhDB{S?OVE8VO8)uYAkNbPFWl}F!t%+kmr9i z+_hqORf$1Ix3Wr=gQdaZE0-jfc_X4%W^C^&vUpY1emiz9!DdC@-^4Y+ zcTL2<7aD7?H6!ss9s1&kzX+S}AHwyJ03V=wFHZ8ly*|DEq5cHo$x1Uw=VQ?3*{uvovi%+Io7ur4|*wpM9{j!JbSm2|HqX3MWP&8}^I%eF*S zS)%xu;7yeQ8C3{{_Yd_t2C$0AE1d1udZ}#qs$FnJ-%mY{;ZA9|T!|Tn zBoxSkE?~F?2Istay>WUwi8l7net|o2On=Dd?DizVq3^Nks6-~!eEkLK)-?hP$yC*o31c(eP$a5V8?_?Fp_W9vJBWsNR)feR}5GE(2F6R@) zI`X2I=LZspf3$HueEK3GdZOyOU%g&OZSq?=;me&QvHSO4svzaSvE^gg_B#(RM!)A6 z<*?(I9sqJUQgGHgd9fCakb3peHM%PTF8YWd3UqaFR=Dc3?Fw;vOtRABufppu{tCFU zVfi^Xg1RCy0nrWMFPiZzKeFQ1_cT}=_4FdeZ*_buYio>TYF2LdywsVHoB1|>=XKWB zY)#?aeVLt!*fm=M zx6~fy(>N7>xe8Uq(YkTzrZMqnJIrId@LsOw1J8OT|F}5xWu;dfv1mIE#VDr^Cvg0n zabkwwCsZmv;?K0B4+E+N6yJJ2mMwS===d%7U0cyGgNJH0H_LZ6UL}byj%EJ}vjo?> z_p%Y)sT?{IqrI)qOo*$Cikai-ZS9_o&)A69$@2|7J43pql6F|y?R6E#%~II6ziQr$ za1#pHl^K8_&mW}y!(bchYPf}(91jbrzW}ReJvMT(`SBAi53b*qLhvK<%7& zvh$j*Ymi+kPeqlU#9)BBa=g_2mfo|6Kro9#p#Q_(dqy?6cI(2+jv^u|A{|9Vy3#u+ z2nqrsCG?0$H=*|?7GP1OOYel5&^ts$K)Te>0@6zep@l#o$$8lCyT5nI+Ix)ejB$Rh ze;73+_dV-%U2{GVS+T#RU7)8vE$-Z^V)eT@27D9vRj8xkC@6c+zTk>q$WhC{&E`ol zN6O4*Gv(f*H6Y750ix~l4NFJ3G-G?1>Z%cHpW8*)#G!N+y4`J3==n)(Xx6{xeu;Uo{ZUdejg|Bjy@tO;yD6@P9YaG{bzq zwt+S}K})o+6VqlXGZ39lRJ)dla=_Q>Bf7JzjfZ)LVZF4Ej+qlwVjkQVjh}c8V1! zsI6CiQjw%nlcZBo82jlLwUtyO`BR?Y?S$*&J80*1=35N(;<83=y!a}|t##QSW%SAc zdlK}%S;@FEp-0Ke9>R(a!WQIoRg-clg}ZQ1lh!JmD?q|sjTTi+MDq3Bp$5P>1^p`P zLSqe1CkV;aZGzc#VvJwQ4Ya zD*Tl)N3(@u9bB@nxRSAW)^ea0H$Ude@e+9+(idN0qy8^b>o&?8%C*~?gmIh}pPQTA z!D)iWs(*0jFaR}HU<2D=^a5qnR7HbTj`@Mol8D`FyJtX_tPSHSRuTG<2<;167mV1G~znhLo zJ6@rU(sR_>fWmf<$gEBAzCy)E1UhfSuUP^z2}MrqUJF zuAr+l$7|Ko@DmGTFAr(Tf${8Ja-<;J)#Cm52_u^)4uc^tHpBocsLAF!ZUt^)1qD-5{FxwYx)3<=;U`8XZ8XM(3bHx)t zB_^nB*kpV6IWFJ(=1UsK^YG68-c>aasV3W`L=%quppM6@5!67@g_Hu~krE!84 z5wq7`v4Hlz*H;Dj(0054R%Che;EjIF+n40L>HOaeF{-35mm!n zur_cbr94R-^3q%8U9w!cwEcQMjD6jv%f1~hTc3f(Ruh%f%z;vZODhAIfi@i(vR`^U zuVwgvqDIee_Q2S+yUmb>(7}O}kd!H%FL;d)p%) zylx1isoz~InvN&2miK)YPaA&Bzio-W$)#!MV3>QsP{K6~sCOF>R=vY5{|oTIK{q+i zeJp4xa2N+jBwR^byP4{n=l|hf4Z{xDJB@8z1ffVE%ezyj-rzgnoBSs5;f!{Q zU~w50lsFpJ>8kukC$x0{V5%M=`Aq2INgZoIEPswvI3v!fgD+Pq%Q6(R&su2zIk)*^ z&x>va#buAo*zS|;8YvAISj{ni>UnjtadW+yNYbSK+Jj0Tek^thriX&izYj^)%e4+V z9;(&>V_cm2ME6&08i?{23KSkCbmc%nR%5=mfui>kiK_(7gB#~}LQL`XCG~8vN$Nex z9dna78Iko^XMPMGOurz zY^`U3I6jDgt$$E8c9OLc$13jn^R3EynJ9b$tF_%#zq%2XT}`xr8smpT zk~YU0cr3GRP1_!93DwrwQy!)|Thx>iKnOio-|dx&YNBS6n2K+@Z;xL3PMi5OTe05K z-pQYOVBielz*!F1IvZg_Zr@$5=M@}rr)ID_LP>~$wk$wU{5ZqD+_D*?NCWJ^0^fL_ zOy3pP-zU7LODdUOmCCI}(@D>HB&Z>~nGo8l@ui}9!+*S%pcg+69hbVm#Q8gT!tvbt zG!^uEQnqFGv6vTi{fW;Kw77=QoYSuf zAOo0R|EJNP6&prH%S6%$hEEu0{>$x3bg+STe$_WzR z-cE8cpR%Tbj+A%?DkC?Gu|Lg-SLt~!jy85A5@+9o-pIdgAgqHhyYBbKsMf}PM2n5o za7_bXm@eG9!s_L=9kdzvy3kS)WuV0KX7|=`zRp)J;+QveN8Pu>lxvPCN!k(_LeD-7w?_xD`s1@%PKgv}$7RZ{m#KIFEbH8<1LHkg@TvKtygI++M(*8Hx@H1V(2ozl%sihoz|S}Cut+8iZGC)u>QVAn zprN3QXXF&fThKY_A&NirGB9bW`)5d)xAq)1-bL#hSDq2KOYutzv+K66C-bznwtggi zpkX5iC@(lXcIp3qnk4BTozlBg4;z6c?UQKhDIE;_?)Tx>n@M?>yw=DQ^y#6pxtbD= zt9-JXlbHBLT0lK>#N#wf|g?2yaMXx$w z6vw?e2%scTfw5M4#EmQ-m?8DTI@(}bqaUB31+kSzcB^I1eoYE_th`}1S z)#2-#LgkVVORfu=Kt0vDD$LAipuK7LABpzm+a{8K^qbpG)WO1jdO}T?#O!haCi3PQ z!c4Cv=(Ko6TC{G%WQw5g@lreZF@SuPc@(al|NZzmz^AXA+lj3)An`df&Zy&}vQ@G8 zEE$!^r7pwIIhQZgri37OWa^-e&!);@KaywTTT*QU#df|$Z^~=?}d;Btcx~Ce({qisq{DZ|grr+hR3CIHftT(?D6I(gVTl`^) zXlEaJhP+PtF1iu`5LFd^=e=a>A80(9SaeF$9lY`22rx>=#2uoeWk%I^6$7tMcB9S& zJ{YSs*v3DnWZv|?w2m?|kv63-H*up0+rXy#BL?0*M%uOHVaMHnWoVS>Jhal0)EzF= z6Kl~}=!!eanR(miIUwknxw+BFo@K}QU>X%GPcVDVuK_eXM zok>ZWZ+;(WGq8g@NsQyxeSz#D^A+!hY50>G+8R}5xW_<}`^Czxt}~uiT`Mk0{_+hF z5Ub3Zk!Blc`!3DS*pHiJK*#gaI9YDKr=RGv_3RE~z?)En>O$9Q+JqFQni)|ciyNm& zc5MxB;_IEavy{tnBS}1VbV% zLZ{=(YJbW5^@i-CSz~x`Dz*;fHaJE4%8Lf{DV;!V|0yy)n-otc^~HnmQab1*PJx0&<03hPqiwl>8) zu=#B}X(d}copwwB!F&UBgNqBM-)t#8=k@4}xJuKmyot|ONzd|+wC(Bx)QHGVq1~j` z!EW1f&&_%~*^jt_vUnKemxF@TQ+nZMnhk)E!S`@qr)Q6RuEey4dszaOuJ@Fo>J3~? zz&dkesfLQTJ3cze*7PwF0Jy403^8GaNup*SO61&_VfAR+f%=M)KZ?+jw}zWb&x(f^ z)*$=#`HEK)CfwErthAd&c}?LHm7-l*H%_bs;%!$NE10`BWGRys-K#Mgv-$qrI7|a{`W^Lm(IUa6w`pX}z{1)Ul6m-x zJb_p%%1p+o`c;tjqlS1({Xs&fp`)_RA>%i=lCDgANIkAB0;80QzvYLX zTw&r>9EEN#RE~Kaj?Iki@+N)G-{drX5p&iR5}C@*DwnzwPDJjwjcWVKIprIs4BuZP z>q$lIEmPC@PMDTPTp{{(-eUwMrWd2U&F|(vZIP0qq*S~SoN453 zuMmTbK~SU}%pH^P7`&@aoXCO*#EoL`w29`C6zb)HSDr&I-uJbrpRVRpen1zd!{2ol6T+r8^=Kp=(11!9$Ei5 znfJ$6b+*&T588?QGQ|rlsexEyZv{IJV&VuzXH?0r4F#++q)!^0VL894>ak&2V8Pij z^J$kYcIf~dD`gkecyMXq$>P2E{0|tx=&y zBDSpMT1y-V_Aq39{S8$yg!?GoP57lqn9MqdYaX)&`Afe8#^ixXhe}d#|8Wlbg!bEC zcWF-^Kj*q@PnP|vhW3S~DVbROXMd)E6R?!6^(ol=!PzCJv-O;xn%-s`+emgLM0@vG zc3{29<-V zw75Yxx3iePNsdfM6_>R|dUWhgo<8dG{j8UNuLA(F*#Cy_0tjA}MgnHxDrnBKxRzTo zZfm&+kV9NzI?1K^Wktt$E-9oTzl^2zN?33iVj$16$7=(iLH)(J_mf+>?0am40foWh zx5vn$X=;1hiC#3_0}(ii?O;9BRtM>()J84#1IP$FKfZvDx;6tj)+Hy~&F;B6BzB*`z|O$XPg4IE8p6N)*>@capK^B%UG`~v*>up9W538(DFa=&u&^_}?duAy zXp28k%9@Fmr|98erUMaZZ_WqCUv7As*f{R&aeMjg9ngW@Z=bteyE6DJICL-oZ0k3*NyH#ITe;;mF(Nl5R! zII}-pL(le-t`VYc?N*cI-cuuIBN`~vds9b8UK;;($2)hKW@djhUOzlc_LA9oR{b82 z8`PGqcMa=)ZIk^tS?ln>wE!2r7G!-7TXiPPr@R!s(eCXug*urj*k(5=K8knM)PeOo zKuX%LA#MZ-R}IBoT7h+qSC$V8>dcYO(XMQF2#YLk7mPQ#`{yrm;kQqVpW3%y8PGVz z)}-Pn0Q%m0=Z>iEbhl~7QD6{d7CXl(o>B-T`SVcawVM_k$4}7dGDmI4T)^+$#ao5b zr?22vZsT+rK66^YYq>q=O)8Dk*yB4Km*p`Po=uh(N*#N`2Oy#qsiY~o#Z2eLGhC{k z!GnhW?m!#XVnnZaKx#rOX)kDp*4{CVujkQD*N%+})lzr5*sXAlL|F2!6>)lyykkl9 z9tq!x?~fZGfx^H0B+Vsj|@3j5eCp58?`j zh*Rfi?bd)OyghaeEAKHnpbv11&_IOhH9^aQrqo|~4_>y^SM7|bX%-j`Yx9S*7e|#L z7^nG%K*CEaD0%V)$=+~Q_|O*>oNoWEKW|Q0qFw?hR+8M(IrTJhYW4ak{jP-augyZW zUQh$CAqLQ>SbT8h+*H+g+JDIIKe+dF+WKqOov0fF12@E78&x%BT@n_S?u=#}Tw)pe zmhO*A`G{O@*bj}3jecdb9)kv!VUAqQHz)~VTN_iy!mT-jHl0;IBT^d(s}&u>_?3Ca z_>+y|VE?K#zgpM=0Z=sn1)E%wi{kIBxx$xtAP@czIwOwSx7%Ub^n`J908?R_k+V0f z?ZbQ;oS~uWN!*kZmRZ3EEwHTIRPiSY@%t{}&#-D3+hd~?HMVh-)HF=& znwZNsdb?T3+e98-V*69tJ9*(4>V3xkuUAoa2U0oSuRpg>M%p)ao3&iUXfXGs-`UBr zP#Kh8XYO;jU;XCi9%07F`iq~&hs%TVDeJ&{ZtN!uG)JcYNU=0_cRM&={Cve(gVri9 zNX&Qpw1<_C&dJ`JEiyLjgt|Q=w0-s9DRN{oY|heCKxpr?X>-hl8+~hT-%t$a zHRLrrqM{4uS3(y|D?|&Y7Oiw-bp`SM%Q^54D)aTpD$s=6djD=}LzTsWM#*&TW25RI z_Mf5O0|jJi64&cWDg-2I5*KFN0DLUnZ@=Xtt|t-sq&+&CkDzX^fBV5hl!<3qeI?Sk zCs~YYT%McTAh{X}f=RQD^v~@s-v1Vgs%&~*<5H)m&NM`05HR@JZUF#2AuAVpl8{f3 zf(H{mhzZZs7g@FS?lv$C{7uQYXkmxGq6WhEfYx>)i6$?b?h3jQ0!v-ts|t6o8v=A> zw&?OY!}Mw6s={<=0oW@$yFJ2gEF_M&*uXC0yL*yV=FJ@YrO_nrrM5ev48pwj)eVQY zwmrEGvOKC;!wBQ!_+&gF%dLF1VCYQpCpUHOdMZ2Yo6!!Jn)2iQYhfkiA{1ocOD1k~ zHKuOUf*}>wP!G1Z3lo>y(~+lCck883iomtAlj_}@+kDtoQ3Z5cB+G7#528z?y&{0V zH<0HujW&1v_0o=4xBP=t4bnIj0kP|4Tw8GSu5|1Yb6wvIzC?1rZJi8fMZ9P)5~!{s z9pN&%7_q>$?bkRq+woGi^Fe(~DWvvjVE(meT#D^VcI1z7CQ5d6JH3y-KHCGZ{2WX_JN8uxbdq|bpz+Pf&NGsGa`cE z69iRPGK$D}ESK)C%V(H)@7na#t4sOJTPrP93Vo*g%nz=vm80>Jr-ePB$*b(j{S_BeJA1t>}wVNiNvb^DisWus}Hcxo5m1zqx zU5_lop1&q&s6>N7>5UEK>J;icjpKF=xpH;3=V4gUPZV^t7zu)hTT9d{SMe0;G{5=s zh?YgF*p<2Zo}}pT)LHBBzg;uc2W|xi;b#){$6yzZPX-)tGyb|XtcBkw!+;A>@w3k4 zj*XV%pufMb)RVIbF~GhgNrSX$J#NoUcSHS;T}x6w zcZ}gWeZ23c0tCmX$7$6|06p7SCewmn05+3(;OcM%YSa)SPt>9>0K>L(;=s&t<-asN zDbm+xxYB3-^3jiX_+LVRyuhnkc}!)c@Fh>dxPs{9TCXEjk>}`GKL!*{fE1MdLuF|m zC8~1{D4~3V7N$y%rVQq%Dix>9bw(@q-SQ-l_XYp?N^}61�ww_IPUIc>cv8I|PVk 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 diff --git a/readme.md b/readme.md index 5326d89dfa36..e54b873cf183 100644 --- a/readme.md +++ b/readme.md @@ -1,8 +1,10 @@ -[Feature Flag, Remote Config and A/B Testing platform, Bullet Train](https://bullet-train.io/) +[Feature Flag, Remote Config and A/B Testing platform, Flagsmith](https://flagsmith.com/) [![Donate](https://liberapay.com/assets/widgets/donate.svg)](https://liberapay.com/Bullet-Train/donate) -# Bullet Train REST API +Bullet Train is now Flagsmith read about it [here](https://flagsmith.com/blog/rebrand). + +# Flagsmith REST API ## Development Environment @@ -132,8 +134,10 @@ The application relies on the following environment variables to run: * `EMAIL_BACKEND`: email provider. Allowed values are `sgbackend.SendGridBackend` for Sendgrid or `django_ses.SESBackend` for Amazon SES. Defaults to `sgbackend.SendGridBackend`. * `SENDGRID_API_KEY`: API key for the Sendgrid account * `SENDER_EMAIL`: Email address from which emails are sent -* `AWS_SES_REGION_NAME`: If using Amazon SES as the email provider, specify the region (e.g. eu-central-1) that contains your verified sender e-mail address. Defaults to us-east-1 +* `AWS_SES_REGION_NAME`: If using Amazon SES as the email provider, specify the region (e.g. eu-central-1) that contains your verified sender e-mail address. Defaults to us-east-1 * `AWS_SES_REGION_ENDPOINT`: ses region endpoint, e.g. email.eu-central-1.amazonaws.com. Required when using ses in a region other than us-east-1 +* `AWS_ACCESS_KEY_ID`: If using Amazon SES, these form part of your SES credentials. +* `AWS_SECRET_ACCESS_KEY`: If using Amazon SES, these form part of your SES credentials. * `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 @@ -148,6 +152,18 @@ The application relies on the following environment variables to run: * `USER_CREATE_PERMISSIONS`: set the permissions for creating new users, using a comma separated list of djoser or rest_framework permissions. Use this to turn off public user creation for self hosting. e.g. `'djoser.permissions.CurrentUserOrAdmin'` Defaults to `'rest_framework.permissions.AllowAny'`. * `ENABLE_EMAIL_ACTIVATION`: new user registration will go via email activation flow, default False +### Creating a secret key + +It is important to also set an environment variable on whatever platform you are using for +`DJANGO_SECRET_KEY`. There is a function to create one in `app.settings.common` if none exists in +the environment variables, however, this is not suitable for use in production. To generate a new +secret key, you can use the function defined in `src/secret-key-gen.py` by simply running it from a +command prompt: + +```bash +python secret-key-gen.py +``` + ## Adding dependencies To add a python dependency, add it to requirements.txt / requirements-dev.txt with it's current version number. @@ -197,16 +213,16 @@ issue please search existing issues in order to prevent duplicates. ## Get in touch If you have any questions about our projects you can email -support@bullet-train.io. +support@flagsmith.com. ## Useful links -[Website](https://bullet-train.io) +[Website](https://www.flagsmith.com) [Product Roadmap](https://product-hub.io/roadmap/5d81f2406180537538d99f28) -[Documentation](https://docs.bullet-train.io/) +[Documentation](https://docs.flagsmith.com/) -[Code Examples](https://github.com/BulletTrainHQ/bullet-train-examples) +[Code Examples](https://github.com/Flagsmith/flagsmith-train-examples) [Youtube Tutorials](https://www.youtube.com/channel/UCki7GZrOdZZcsV9rAIRchCw) From 754b2dbfe23f95fc97ff1aa3c77a4a602faf5ce0 Mon Sep 17 00:00:00 2001 From: "mateusz.szlendak" Date: Wed, 25 Nov 2020 15:32:54 +0100 Subject: [PATCH 31/47] capitalize resources --- src/analytics/influxdb_wrapper.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/analytics/influxdb_wrapper.py b/src/analytics/influxdb_wrapper.py index 561b5fb5f61f..735176a7cecf 100644 --- a/src/analytics/influxdb_wrapper.py +++ b/src/analytics/influxdb_wrapper.py @@ -120,6 +120,6 @@ def get_multiple_event_list_for_organisation(organisation_id: int): for result in results: for i, record in enumerate(result.records): - dataset[i][record.values["resource"]] = record.values["_value"] - dataset[i]["name"] = record.values["_time"].isoformat() + dataset[i][record.values["resource"].capitalize()] = record.values["_value"] + dataset[i]["name"] = record.values["_time"].strftime("%Y-%m-%d") return dataset From aeb48f4427a74429b5f8da23648c19eec1dc6465 Mon Sep 17 00:00:00 2001 From: Ben Rometsch Date: Sun, 29 Nov 2020 12:30:22 +0000 Subject: [PATCH 32/47] Feature/ch1100/tweak sales dashboard --- .../templates/sales_dashboard/home.html | 99 ++++++++++++--- .../sales_dashboard/organisation.html | 119 +++++++++++------- src/sales_dashboard/views.py | 21 +++- src/templates/admin/login.html | 0 4 files changed, 173 insertions(+), 66 deletions(-) create mode 100644 src/templates/admin/login.html diff --git a/src/sales_dashboard/templates/sales_dashboard/home.html b/src/sales_dashboard/templates/sales_dashboard/home.html index 9fdd7c570833..75e684626262 100644 --- a/src/sales_dashboard/templates/sales_dashboard/home.html +++ b/src/sales_dashboard/templates/sales_dashboard/home.html @@ -13,12 +13,12 @@ -
-
+

Organisations

@@ -33,11 +33,10 @@

Organisations

Projects Flags Segments - Users - {% for org in object_list %} + {% for org in object_list %} {{org.id}} {{org.name}} @@ -46,32 +45,94 @@

Organisations

{{org.projects}} {{org.flags}} {{org.segments}} - {{org.users}} - {% endfor %} + {% endfor %} - +
+ +
+

Projects

+
+ +
+ + + + + + + + + + {{page_title}} + {% for project in projects %} + + + + + + {% endfor %} + +
IDNameOrganisation
{{project.id}}{{project.name}}{{project.organisation.name}}
+
+ +
+

Users

+
+ +
+ + + + + + + + + + + + + {{page_title}} + {% for user in users %} + + + + + + + + + {% endfor %} + +
IDEmail AddressNameOrganisationsJoinedLast Login
{{user.id}}{{user.email}}{{user.first_name}} {{user.last_name}} + {% for org in user.organisations.all %} + {{org.name}} + {% endfor%} + {{ user.date_joined }}{{ user.last_login }}
-{% endblock %} - +{% endblock %} \ No newline at end of file diff --git a/src/sales_dashboard/templates/sales_dashboard/organisation.html b/src/sales_dashboard/templates/sales_dashboard/organisation.html index 7223df943913..ad3caa1cefe7 100644 --- a/src/sales_dashboard/templates/sales_dashboard/organisation.html +++ b/src/sales_dashboard/templates/sales_dashboard/organisation.html @@ -18,40 +18,71 @@
-
+

{{organisation.name}}

Plan: {{ organisation.subscription.plan|default:"Free"}}

Seats: {{organisation.subscription.max_seats|default:0 }}

+

Projects

+
+ + + + + + + + + + + + + {% for project in organisation.projects.all %} + + + + + + + + + {% endfor %} + +
IDCreatedNameFeaturesSegmentsEnvironments
{{project.id}}{{project.created_date}}{{project.name}}{{project.features.all.count}}{{project.segments.all.count}}{{project.environments.all.count}}
+
+

Users

- - - - - - - + + + + + + + - {% for user in organisation.users.all %} - - - - - - - - {% endfor %} + {% for user in organisation.users.all %} + + + + + + + + {% endfor %}
IDNameEmail AddressDate RegisteredLast Logged In
IDNameEmail AddressDate RegisteredLast Logged In
{{ user.id }}{{ user.first_name}}{{user.email}}{{ user.date_joined }}{{ user.last_login }}
{{ user.id }}{{ user.first_name}}{{user.email}}{{ user.date_joined }}{{ user.last_login }}
- -
- -
+
+
+ +
+ +
@@ -59,7 +90,6 @@

Users

{% endblock %} - {% block script %} {% endblock %} \ No newline at end of file diff --git a/src/sales_dashboard/views.py b/src/sales_dashboard/views.py index 944e2d680c66..7c80e0fb25c8 100644 --- a/src/sales_dashboard/views.py +++ b/src/sales_dashboard/views.py @@ -6,13 +6,15 @@ ) from django.core.paginator import Paginator from django.contrib.admin.views.decorators import staff_member_required -from django.db.models import Count +from django.db.models import Count, Q from django.http import HttpResponse from django.template import loader from django.utils.safestring import mark_safe -from organisations.models import Organisation +from organisations.models import Organisation, UserOrganisation +from projects.models import Project from django.shortcuts import get_object_or_404 from django.views.generic import ListView +from users.models import FFAdminUser OBJECTS_PER_PAGE = 50 @@ -64,6 +66,21 @@ def get_queryset(self): ) return list_of_organisations + def get_context_data(self, **kwargs): + data = super().get_context_data(**kwargs) + + if "search" in self.request.GET: + search_term = self.request.GET["search"] + projects = Project.objects.all().filter(name__icontains=search_term)[:20] + data["projects"] = projects + + users = FFAdminUser.objects.all().filter( + Q(last_name__icontains=search_term) | Q(email__icontains=search_term) + )[:20] + data["users"] = users + + return data + @staff_member_required def organisation_info(request, organisation_id): diff --git a/src/templates/admin/login.html b/src/templates/admin/login.html new file mode 100644 index 000000000000..e69de29bb2d1 From 4a6284c6338ea54102204748a7bb6db7fda740c2 Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Sun, 29 Nov 2020 12:57:49 +0000 Subject: [PATCH 33/47] Fix typo in gitlab ci --- .gitlab-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index f69ae8712cc4..cc26149bbaf6 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -56,7 +56,7 @@ deployawsmaster: variables: ENVIRONMENT_NAME: production-api AWS_ACCESS_KEY_ID: "$AWS_PRODUCTION_ACCESS_KEY_ID" - AWS_SECERET_ACCESS_KEY: "$AWS_PRODUCTION_SECRET_ACCESS_KEY" + AWS_SECRET_ACCESS_KEY: "$AWS_PRODUCTION_SECRET_ACCESS_KEY" script: - *deploy_to_beanstalk only: From 5496b9b908e11d14262105fb63e0382eee0a4ea6 Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Sun, 29 Nov 2020 13:33:28 +0000 Subject: [PATCH 34/47] Refactor all environments to use database url --- src/app/settings/common.py | 30 ++++++++++-------------------- src/app/settings/develop.py | 8 +------- src/app/settings/local.py | 11 ----------- src/app/settings/master.py | 8 -------- src/app/settings/staging.py | 8 -------- src/app/settings/test.py | 8 +------- 6 files changed, 12 insertions(+), 61 deletions(-) diff --git a/src/app/settings/common.py b/src/app/settings/common.py index 34e79980fed7..898e6fc7d533 100644 --- a/src/app/settings/common.py +++ b/src/app/settings/common.py @@ -10,19 +10,17 @@ https://docs.djangoproject.com/en/1.9/ref/settings/ """ import os +import sys import warnings +from datetime import timedelta from importlib import reload +import dj_database_url import environ import requests -import sys - from corsheaders.defaults import default_headers -from datetime import timedelta - from django.core.management.utils import get_random_secret_key - env = environ.Env() # Build paths inside the project like this: os.path.join(BASE_DIR, ...) @@ -127,8 +125,9 @@ SITE_ID = 1 -# Initialise empty databases dict to be populated in environment settings -DATABASES = {} +DATABASES = { + "default": dj_database_url.parse(os.environ["DATABASE_URL"], conn_max_age=60) +} REST_FRAMEWORK = { "DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticated"], @@ -200,15 +199,9 @@ { "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", }, - { - "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", - }, - { - "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", - }, - { - "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", - }, + {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, + {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, + {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, ] AUTHENTICATION_BACKENDS = ( @@ -317,10 +310,7 @@ }, "loggers": { "django": {"level": "INFO", "handlers": ["console"]}, - "": { - "level": "DEBUG", - "handlers": ["console"], - }, + "": {"level": "DEBUG", "handlers": ["console"]}, }, } diff --git a/src/app/settings/develop.py b/src/app/settings/develop.py index 6d441e8c02e3..cdcbeb79ac90 100644 --- a/src/app/settings/develop.py +++ b/src/app/settings/develop.py @@ -1,10 +1,4 @@ -import os - -import dj_database_url - -from app.settings.common import * - -DATABASES["default"] = dj_database_url.parse(os.environ["DATABASE_URL"]) +from app.settings.common import * # noqa DEBUG = True diff --git a/src/app/settings/local.py b/src/app/settings/local.py index 645da02174d3..5c9a047a93e8 100644 --- a/src/app/settings/local.py +++ b/src/app/settings/local.py @@ -10,15 +10,4 @@ DEBUG = True -DATABASES = { - "default": { - "ENGINE": "django.db.backends.postgresql", - "NAME": os.getenv("POSTGRES_DATABASE", "bullettrain"), - "USER": os.getenv("POSTGRES_USER", "postgres"), - "PASSWORD": os.environ["POSTGRES_PASSWORD"], - "HOST": os.getenv("POSTGRES_HOST", "127.0.0.1"), - "PORT": os.getenv("POSTGRES_PORT", 5432), - } -} - EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" diff --git a/src/app/settings/master.py b/src/app/settings/master.py index 79272f3c17d3..6e271c9dc11c 100644 --- a/src/app/settings/master.py +++ b/src/app/settings/master.py @@ -1,13 +1,5 @@ -import os - -import dj_database_url - from app.settings.common import * -DATABASES["default"] = dj_database_url.parse( - os.environ["DATABASE_URL"], conn_max_age=60 -) - DEBUG = False LOGGING = { diff --git a/src/app/settings/staging.py b/src/app/settings/staging.py index 79272f3c17d3..6e271c9dc11c 100644 --- a/src/app/settings/staging.py +++ b/src/app/settings/staging.py @@ -1,13 +1,5 @@ -import os - -import dj_database_url - from app.settings.common import * -DATABASES["default"] = dj_database_url.parse( - os.environ["DATABASE_URL"], conn_max_age=60 -) - DEBUG = False LOGGING = { diff --git a/src/app/settings/test.py b/src/app/settings/test.py index cdbb9d4a98fb..19963014dc14 100644 --- a/src/app/settings/test.py +++ b/src/app/settings/test.py @@ -1,7 +1 @@ -import os - -import dj_database_url - -from app.settings.common import * - -DATABASES["default"] = dj_database_url.parse(os.environ["DATABASE_URL"]) +from app.settings.common import * # noqa From 18ca36fde99f13d91d1986024bcf7f9246e271fe Mon Sep 17 00:00:00 2001 From: Ben Rometsch Date: Mon, 30 Nov 2020 11:04:19 +0000 Subject: [PATCH 35/47] Merge request template --- .gitlab/merge_request_templates/general.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .gitlab/merge_request_templates/general.md diff --git a/.gitlab/merge_request_templates/general.md b/.gitlab/merge_request_templates/general.md new file mode 100644 index 000000000000..35ce8d7f5aea --- /dev/null +++ b/.gitlab/merge_request_templates/general.md @@ -0,0 +1,7 @@ +## Overview of the Merge Request + +This Merge Request works by... + +## Have you written tests? + +If not, explain here! \ No newline at end of file From 96c78f891355e5016ca513e52123bd03339999ec Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Mon, 30 Nov 2020 22:13:21 +0000 Subject: [PATCH 36/47] Start refactoring settings to (hopefully) fix deployments --- src/.ebextensions/db-migrate.config | 8 +++++--- src/app/settings/common.py | 2 ++ src/app/settings/develop.py | 2 +- src/app/settings/local.py | 2 -- src/app/settings/master.py | 2 +- src/app/settings/production.py | 21 +++++++++++++++++++++ src/app/settings/staging.py | 2 +- 7 files changed, 31 insertions(+), 8 deletions(-) create mode 100644 src/app/settings/production.py diff --git a/src/.ebextensions/db-migrate.config b/src/.ebextensions/db-migrate.config index d8b8295bccd2..b50003e905cc 100644 --- a/src/.ebextensions/db-migrate.config +++ b/src/.ebextensions/db-migrate.config @@ -1,6 +1,8 @@ container_commands: 01_migrate: - command: "django-admin.py migrate" - leader_only: true + command: django-admin.py migrate 02_collectstatic: - command: "source /opt/python/run/venv/bin/activate && python manage.py collectstatic --noinput" \ No newline at end of file + command: python manage.py collectstatic --noinput +option_settings: + aws:elasticbeanstalk:application:environment: + DJANGO_SETTINGS_MODULE: app.settings.production \ No newline at end of file diff --git a/src/app/settings/common.py b/src/app/settings/common.py index 898e6fc7d533..f48dc83cc1f5 100644 --- a/src/app/settings/common.py +++ b/src/app/settings/common.py @@ -33,6 +33,8 @@ "ENVIRONMENT env variable must be one of local, dev, staging or production" ) +DEBUG = env("DEBUG", default=False) + SECRET_KEY = env("DJANGO_SECRET_KEY", default=get_random_secret_key()) HOSTED_SEATS_LIMIT = env.int("HOSTED_SEATS_LIMIT", default=0) diff --git a/src/app/settings/develop.py b/src/app/settings/develop.py index cdcbeb79ac90..1c944c2953a0 100644 --- a/src/app/settings/develop.py +++ b/src/app/settings/develop.py @@ -1,6 +1,6 @@ from app.settings.common import * # noqa -DEBUG = True +# TODO: remove this in favour of production.py and environment variables LOGGING = { "version": 1, diff --git a/src/app/settings/local.py b/src/app/settings/local.py index 5c9a047a93e8..02441b75e5c0 100644 --- a/src/app/settings/local.py +++ b/src/app/settings/local.py @@ -8,6 +8,4 @@ MIDDLEWARE.extend(["debug_toolbar.middleware.DebugToolbarMiddleware"]) -DEBUG = True - EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" diff --git a/src/app/settings/master.py b/src/app/settings/master.py index 6e271c9dc11c..e51aa761358a 100644 --- a/src/app/settings/master.py +++ b/src/app/settings/master.py @@ -1,6 +1,6 @@ from app.settings.common import * -DEBUG = False +# TODO: remove this in favour of production.py and environment variables LOGGING = { "version": 1, diff --git a/src/app/settings/production.py b/src/app/settings/production.py new file mode 100644 index 000000000000..5f2e08b09da2 --- /dev/null +++ b/src/app/settings/production.py @@ -0,0 +1,21 @@ +from app.settings.common import * + +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "verbose": { + "format": "%(levelname)s %(asctime)s %(module)s %(process)d %(thread)d %(message)s" + }, + "simple": {"format": "%(levelname)s %(message)s"}, + }, + "handlers": { + "console": {"class": "logging.StreamHandler", "formatter": "verbose"}, + }, + "loggers": { + "django": {"handlers": ["console"], "propagate": True, "level": "INFO"}, + "gunicorn": {"handlers": ["console"], "level": "DEBUG"}, + }, +} + +REST_FRAMEWORK["PAGE_SIZE"] = 999 diff --git a/src/app/settings/staging.py b/src/app/settings/staging.py index 6e271c9dc11c..e51aa761358a 100644 --- a/src/app/settings/staging.py +++ b/src/app/settings/staging.py @@ -1,6 +1,6 @@ from app.settings.common import * -DEBUG = False +# TODO: remove this in favour of production.py and environment variables LOGGING = { "version": 1, From c2a2c59deb12d438aaba28070b4fc7c34a16e168 Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Mon, 30 Nov 2020 22:15:31 +0000 Subject: [PATCH 37/47] Re add leader only for migrate command --- src/.ebextensions/db-migrate.config | 1 + 1 file changed, 1 insertion(+) diff --git a/src/.ebextensions/db-migrate.config b/src/.ebextensions/db-migrate.config index b50003e905cc..d163db9c5a6b 100644 --- a/src/.ebextensions/db-migrate.config +++ b/src/.ebextensions/db-migrate.config @@ -1,6 +1,7 @@ container_commands: 01_migrate: command: django-admin.py migrate + leader_only: true 02_collectstatic: command: python manage.py collectstatic --noinput option_settings: From 65475c71a5ca60dba81661e41de229359c1252c5 Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Tue, 1 Dec 2020 22:02:09 +0000 Subject: [PATCH 38/47] Prevent default_enabled being changed on feature updates --- src/features/models.py | 3 ++- src/features/serializers.py | 9 +++++++++ src/features/tests/test_views.py | 33 ++++++++++++++++++++++++-------- src/features/views.py | 16 ++++++++-------- 4 files changed, 44 insertions(+), 17 deletions(-) diff --git a/src/features/models.py b/src/features/models.py index 7f0bcd16a087..74266f71bf1f 100644 --- a/src/features/models.py +++ b/src/features/models.py @@ -82,7 +82,8 @@ def save(self, *args, **kwargs): super(Feature, self).save(*args, **kwargs) - # create feature states for all environments in the project + # create / update feature states for all environments in the project + # todo: is update necessary here environments = self.project.environments.all() for env in environments: FeatureState.objects.update_or_create( diff --git a/src/features/serializers.py b/src/features/serializers.py index d03a687c7ccb..46e9013507a8 100644 --- a/src/features/serializers.py +++ b/src/features/serializers.py @@ -72,6 +72,15 @@ def validate(self, attrs): return attrs +class UpdateFeatureSerializer(CreateFeatureSerializer): + """ prevent users from changing the value of default enabled after creation """ + + class Meta(CreateFeatureSerializer.Meta): + read_only_fields = CreateFeatureSerializer.Meta.read_only_fields + ( + "default_enabled", + ) + + class FeatureSegmentCreateSerializer(serializers.ModelSerializer): value = FeatureSegmentValueField(required=False) diff --git a/src/features/tests/test_views.py b/src/features/tests/test_views.py index 597cceebc679..86e3d2919ee0 100644 --- a/src/features/tests/test_views.py +++ b/src/features/tests/test_views.py @@ -2,6 +2,7 @@ from unittest import TestCase, mock import pytest +from django.forms import model_to_dict from django.urls import reverse from rest_framework import status from rest_framework.test import APIClient, APITestCase @@ -494,6 +495,28 @@ def test_list_features_return_tags(self): feature = response_json["results"][0] assert "tags" in feature + def test_put_feature_does_not_update_feature_states(self): + # Given + feature = Feature.objects.create( + name="test_feature", project=self.project, default_enabled=False + ) + url = reverse( + "api-v1:projects:project-features-detail", + args=[self.project.id, feature.id], + ) + data = model_to_dict(feature) + data["default_enabled"] = True + + # When + response = self.client.put( + url, data=json.dumps(data), content_type="application/json" + ) + + # Then + assert response.status_code == status.HTTP_200_OK + + assert all(fs.enabled is False for fs in feature.feature_states.all()) + @pytest.mark.django_db class FeatureSegmentViewTest(TestCase): @@ -731,14 +754,8 @@ def test_priority_of_multiple_feature_segments(self): assert feature_segment_1.priority == 0 assert feature_segment_2.priority == 1 data = [ - { - "id": feature_segment_1.id, - "priority": 1, - }, - { - "id": feature_segment_2.id, - "priority": 0, - }, + {"id": feature_segment_1.id, "priority": 1}, + {"id": feature_segment_2.id, "priority": 0}, ] # When diff --git a/src/features/views.py b/src/features/views.py index 1ef93653f810..b0ceed55c3eb 100644 --- a/src/features/views.py +++ b/src/features/views.py @@ -42,6 +42,7 @@ FeatureStateSerializerWithIdentity, FeatureStateValueSerializer, FeatureWithTagsSerializer, + UpdateFeatureSerializer, ) logger = logging.getLogger() @@ -54,12 +55,12 @@ class FeatureViewSet(viewsets.ModelViewSet): permission_classes = [IsAuthenticated, FeaturePermissions] def get_serializer_class(self): - if self.action == "list": - return FeatureWithTagsSerializer - elif self.action in ["create", "update"]: - return CreateFeatureSerializer - else: - return FeatureSerializer + return { + "list": FeatureWithTagsSerializer, + "create": CreateFeatureSerializer, + "update": UpdateFeatureSerializer, + "partial_update": UpdateFeatureSerializer, + }.get(self.action, FeatureSerializer) def get_queryset(self): user_projects = self.request.user.get_permitted_projects(["VIEW_PROJECT"]) @@ -387,8 +388,7 @@ def _get_flags_from_cache(self, filter_args, environment): def _get_flags_response_with_identifier(self, request, identifier): identity, _ = Identity.objects.get_or_create( - identifier=identifier, - environment=request.environment, + identifier=identifier, environment=request.environment ) kwargs = { From 9dc1dca19fd1150b2f4e73f4cf099edc6edeab7d Mon Sep 17 00:00:00 2001 From: Maciej Krol Date: Wed, 2 Dec 2020 10:32:13 +0000 Subject: [PATCH 39/47] Add pre commit instruction to readme --- readme.md | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/readme.md b/readme.md index bacd4e8e219d..c04f47d37fb6 100644 --- a/readme.md +++ b/readme.md @@ -156,6 +156,17 @@ The application relies on the following environment variables to run: * `USER_CREATE_PERMISSIONS`: set the permissions for creating new users, using a comma separated list of djoser or rest_framework permissions. Use this to turn off public user creation for self hosting. e.g. `'djoser.permissions.CurrentUserOrAdmin'` Defaults to `'rest_framework.permissions.AllowAny'`. * `ENABLE_EMAIL_ACTIVATION`: new user registration will go via email activation flow, default False +## Pre commit + +The application uses pre-commit configuration ( `.pre-commit-config.yaml` ) to run black formatting before commits. + +To install pre-commit: + +```bash +pip install pre-commit +pre-commit install +``` + ## Adding dependencies To add a python dependency, add it to requirements.txt / requirements-dev.txt with it's current version number. @@ -175,9 +186,9 @@ given project. The number of seconds this is cached for is configurable using th ## Stack -- Python 2.7.14 -- Django 1.11.13 -- DjangoRestFramework 3.8.2 +- Python 3.8 +- Django 2.2.17 +- DjangoRestFramework 3.12.1 ## Static Files From 5249bb1943337f9a2dca2d1ef810065015ed88d2 Mon Sep 17 00:00:00 2001 From: Mateusz Szlendak Date: Wed, 2 Dec 2020 13:08:10 +0000 Subject: [PATCH 40/47] Feature/ch000/sales dashboard MultiChart Bar --- src/analytics/influxdb_wrapper.py | 11 ++-- .../sales_dashboard/organisation.html | 51 ++++++++++--------- src/sales_dashboard/views.py | 5 +- 3 files changed, 36 insertions(+), 31 deletions(-) diff --git a/src/analytics/influxdb_wrapper.py b/src/analytics/influxdb_wrapper.py index 735176a7cecf..e9fd53ea9a7f 100644 --- a/src/analytics/influxdb_wrapper.py +++ b/src/analytics/influxdb_wrapper.py @@ -1,3 +1,5 @@ +from collections import defaultdict + from django.conf import settings from influxdb_client import InfluxDBClient, Point from influxdb_client.client.write_api import SYNCHRONOUS @@ -88,14 +90,13 @@ def get_event_list_for_organisation(organisation_id: int): drop_columns='"organisation", "organisation_id", "type", "project", "project_id"', extra="|> aggregateWindow(every: 24h, fn: sum)", ) - dataset = [] + dataset = defaultdict(list) labels = [] for result in results: for record in result.records: - dataset.append( - {"t": record.values["_time"].isoformat(), "y": record.values["_value"]} - ) - labels.append(record.values["_time"].strftime("%Y-%m-%d")) + dataset[record["resource"]].append(record["_value"]) + if len(labels) != 31: + labels.append(record.values["_time"].strftime("%Y-%m-%d")) return dataset, labels diff --git a/src/sales_dashboard/templates/sales_dashboard/organisation.html b/src/sales_dashboard/templates/sales_dashboard/organisation.html index ad3caa1cefe7..95d6dc77eed8 100644 --- a/src/sales_dashboard/templates/sales_dashboard/organisation.html +++ b/src/sales_dashboard/templates/sales_dashboard/organisation.html @@ -94,32 +94,33 @@

Users

{% endblock %} \ No newline at end of file diff --git a/src/sales_dashboard/views.py b/src/sales_dashboard/views.py index 7c80e0fb25c8..36a5fdba7ca7 100644 --- a/src/sales_dashboard/views.py +++ b/src/sales_dashboard/views.py @@ -89,7 +89,10 @@ def organisation_info(request, organisation_id): template = loader.get_template("sales_dashboard/organisation.html") context = { "organisation": organisation, - "event_list": mark_safe(json.dumps(event_list)), + "event_list": event_list, + "traits": mark_safe(json.dumps(event_list["traits"])), + "identities": mark_safe(json.dumps(event_list["identities"])), + "flags": mark_safe(json.dumps(event_list["flags"])), "labels": mark_safe(json.dumps(labels)), } From cab6bac6905053a3816ef39630711bc5affbc435 Mon Sep 17 00:00:00 2001 From: Ben Rometsch Date: Fri, 4 Dec 2020 18:19:10 +0000 Subject: [PATCH 41/47] Sentry --- readme.md | 2 ++ requirements.txt | 3 ++- src/app/settings/common.py | 15 +++++++++++++++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/readme.md b/readme.md index c04f47d37fb6..6561c81534d6 100644 --- a/readme.md +++ b/readme.md @@ -155,6 +155,8 @@ The application relies on the following environment variables to run: * `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`) * `USER_CREATE_PERMISSIONS`: set the permissions for creating new users, using a comma separated list of djoser or rest_framework permissions. Use this to turn off public user creation for self hosting. e.g. `'djoser.permissions.CurrentUserOrAdmin'` Defaults to `'rest_framework.permissions.AllowAny'`. * `ENABLE_EMAIL_ACTIVATION`: new user registration will go via email activation flow, default False +* `SENTRY_SDK_DSN`: If using Sentry, set the project DSN here. +* `SENTRY_TRACE_SAMPLE_RATE`: Float. If using Sentry, sets the trace sample rate. Defaults to 1.0. ## Pre commit diff --git a/requirements.txt b/requirements.txt index 90c96e689da0..04840792c1d1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -31,4 +31,5 @@ django-ses==1.0.3 django-axes==5.8.0 django-admin-sso==3.0.0 drf-yasg2==1.19.3 -django-debug-toolbar==3.1.1 \ No newline at end of file +django-debug-toolbar==3.1.1 +sentry-sdk==0.19.4 \ No newline at end of file diff --git a/src/app/settings/common.py b/src/app/settings/common.py index 898e6fc7d533..b45edf20bb46 100644 --- a/src/app/settings/common.py +++ b/src/app/settings/common.py @@ -18,8 +18,10 @@ import dj_database_url import environ import requests +import sentry_sdk from corsheaders.defaults import default_headers from django.core.management.utils import get_random_secret_key +from sentry_sdk.integrations.django import DjangoIntegration env = environ.Env() @@ -401,3 +403,16 @@ "/admin/login/?next=/admin", "/admin/", ] + +# Sentry tracking +SENTRY_SDK_DSN = env("SENTRY_SDK_DSN", default=None) +if SENTRY_SDK_DSN: + sentry_sdk.init( + dsn=env.str("SENTRY_SDK_DSN"), + integrations=[DjangoIntegration()], + traces_sample_rate=env.float("SENTRY_TRACE_SAMPLE_RATE", default=1.0), + environment=env("ENVIRONMENT", default="unknown"), + # If you wish to associate users to errors (assuming you are using + # django.contrib.auth) you may enable sending PII data. + send_default_pii=True, + ) From c9d631e4cedfeb50f322938c0b968a02cd78527e Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Sat, 5 Dec 2020 14:18:55 +0000 Subject: [PATCH 42/47] Add sentry sdk requirement --- requirements.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 90c96e689da0..04840792c1d1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -31,4 +31,5 @@ django-ses==1.0.3 django-axes==5.8.0 django-admin-sso==3.0.0 drf-yasg2==1.19.3 -django-debug-toolbar==3.1.1 \ No newline at end of file +django-debug-toolbar==3.1.1 +sentry-sdk==0.19.4 \ No newline at end of file From 3d718173d29cc3b43e242840e13d63d892e859b7 Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Sat, 5 Dec 2020 14:43:56 +0000 Subject: [PATCH 43/47] Refactor sentry integration --- src/app/settings/common.py | 14 ++------------ src/integrations/sentry/__init__.py | 1 + src/integrations/sentry/apps.py | 20 ++++++++++++++++++++ 3 files changed, 23 insertions(+), 12 deletions(-) create mode 100644 src/integrations/sentry/__init__.py create mode 100644 src/integrations/sentry/apps.py diff --git a/src/app/settings/common.py b/src/app/settings/common.py index 6ab5cb5f9f75..a25c886c096f 100644 --- a/src/app/settings/common.py +++ b/src/app/settings/common.py @@ -18,10 +18,8 @@ import dj_database_url import environ import requests -import sentry_sdk from corsheaders.defaults import default_headers from django.core.management.utils import get_random_secret_key -from sentry_sdk.integrations.django import DjangoIntegration env = environ.Env() @@ -120,6 +118,7 @@ # Third party integrations "integrations.datadog", "integrations.amplitude", + "integrations.sentry", # Rate limiting admin endpoints "axes", ] @@ -408,13 +407,4 @@ # Sentry tracking SENTRY_SDK_DSN = env("SENTRY_SDK_DSN", default=None) -if SENTRY_SDK_DSN: - sentry_sdk.init( - dsn=env.str("SENTRY_SDK_DSN"), - integrations=[DjangoIntegration()], - traces_sample_rate=env.float("SENTRY_TRACE_SAMPLE_RATE", default=1.0), - environment=env("ENVIRONMENT", default="unknown"), - # If you wish to associate users to errors (assuming you are using - # django.contrib.auth) you may enable sending PII data. - send_default_pii=True, - ) +SENTRY_TRACE_SAMPLE_RATE = env.float("SENTRY_TRACE_SAMPLE_RATE", default=1.0) diff --git a/src/integrations/sentry/__init__.py b/src/integrations/sentry/__init__.py new file mode 100644 index 000000000000..5578e2d71346 --- /dev/null +++ b/src/integrations/sentry/__init__.py @@ -0,0 +1 @@ +default_app_config = "apps.integrations.sentry.SentryConfig" diff --git a/src/integrations/sentry/apps.py b/src/integrations/sentry/apps.py new file mode 100644 index 000000000000..d1ad859ead2c --- /dev/null +++ b/src/integrations/sentry/apps.py @@ -0,0 +1,20 @@ +import sentry_sdk +from django.apps import AppConfig +from django.conf import settings +from sentry_sdk.integrations.django import DjangoIntegration + + +class SentryConfig(AppConfig): + name = "integrations.sentry" + + def ready(self): + if settings.SENTRY_SDK_DSN: + sentry_sdk.init( + dsn=settings.SENTRY_SDK_DSN, + integrations=[DjangoIntegration()], + traces_sample_rate=settings.SENTRY_TRACE_SAMPLE_RATE, + environment=settings.ENV, + # If you wish to associate users to errors (assuming you are using + # django.contrib.auth) you may enable sending PII data. + send_default_pii=True, + ) From 0c6e34d76f4361b99889fafb050c7e6d3b0a8f19 Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Sat, 5 Dec 2020 14:46:36 +0000 Subject: [PATCH 44/47] Fix bad app config path --- src/integrations/sentry/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/integrations/sentry/__init__.py b/src/integrations/sentry/__init__.py index 5578e2d71346..56e1a32b0e44 100644 --- a/src/integrations/sentry/__init__.py +++ b/src/integrations/sentry/__init__.py @@ -1 +1 @@ -default_app_config = "apps.integrations.sentry.SentryConfig" +default_app_config = "integrations.sentry.SentryConfig" From b178f4f5ca65d8a06aff64ffab2dae9473a3c03b Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Sat, 5 Dec 2020 14:48:44 +0000 Subject: [PATCH 45/47] Fix bad app config path --- src/integrations/sentry/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/integrations/sentry/__init__.py b/src/integrations/sentry/__init__.py index 56e1a32b0e44..7c1d9e357c9d 100644 --- a/src/integrations/sentry/__init__.py +++ b/src/integrations/sentry/__init__.py @@ -1 +1 @@ -default_app_config = "integrations.sentry.SentryConfig" +default_app_config = "integrations.sentry.apps.SentryConfig" From fd709f83cf49b391440aa1717388cb619ffd2a6c Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Sat, 5 Dec 2020 15:07:19 +0000 Subject: [PATCH 46/47] Add ebignore file to prevent eb deploy from relying on git to generate the app bundle --- src/.ebignore | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 src/.ebignore diff --git a/src/.ebignore b/src/.ebignore new file mode 100644 index 000000000000..e69de29bb2d1 From 166fbf3ebda9b076a9e95ea85c87cb45b1641ca0 Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Sat, 5 Dec 2020 15:32:48 +0000 Subject: [PATCH 47/47] Replace django environ with environs --- requirements.txt | 4 ++-- src/app/settings/common.py | 8 +++----- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/requirements.txt b/requirements.txt index 04840792c1d1..2a1d1c3d08a9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -22,7 +22,6 @@ chargebee==2.7.7 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==3.14.3 django-storages==1.10.1 -django-environ==0.4.5 django-trench==0.2.3 djoser==2.0.5 influxdb-client==1.11.0 @@ -32,4 +31,5 @@ django-axes==5.8.0 django-admin-sso==3.0.0 drf-yasg2==1.19.3 django-debug-toolbar==3.1.1 -sentry-sdk==0.19.4 \ No newline at end of file +sentry-sdk==0.19.4 +environs==9.2.0 \ No newline at end of file diff --git a/src/app/settings/common.py b/src/app/settings/common.py index a25c886c096f..3673b77106f8 100644 --- a/src/app/settings/common.py +++ b/src/app/settings/common.py @@ -16,12 +16,12 @@ from importlib import reload import dj_database_url -import environ +from environs import Env import requests from corsheaders.defaults import default_headers from django.core.management.utils import get_random_secret_key -env = environ.Env() +env = Env() # Build paths inside the project like this: os.path.join(BASE_DIR, ...) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @@ -128,9 +128,7 @@ SITE_ID = 1 -DATABASES = { - "default": dj_database_url.parse(os.environ["DATABASE_URL"], conn_max_age=60) -} +DATABASES = {"default": dj_database_url.parse(env("DATABASE_URL"), conn_max_age=60)} REST_FRAMEWORK = { "DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticated"],