diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 5052df942fc7..cc26149bbaf6 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_SECRET_ACCESS_KEY: "$AWS_PRODUCTION_SECRET_ACCESS_KEY" + script: + - *deploy_to_beanstalk + only: + - master 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 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-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.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/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/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/readme.md b/readme.md index 2fed3f8d2464..1aa3b9d2f210 100644 --- a/readme.md +++ b/readme.md @@ -122,6 +122,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: @@ -151,6 +159,31 @@ 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 + +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 +``` + +### 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 @@ -171,9 +204,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 @@ -186,7 +219,7 @@ that the static files are hosted in. ## Documentation -Further documentation can be found [here](https://docs.flagsmith.com). +Further documentation can be found [here](https://docs.bullet-train.io). ## Contributing diff --git a/requirements.txt b/requirements.txt index 90c96e689da0..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 @@ -31,4 +30,6 @@ 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 +environs==9.2.0 \ No newline at end of file diff --git a/src/.ebextensions/db-migrate.config b/src/.ebextensions/db-migrate.config index d8b8295bccd2..d163db9c5a6b 100644 --- a/src/.ebextensions/db-migrate.config +++ b/src/.ebextensions/db-migrate.config @@ -1,6 +1,9 @@ container_commands: 01_migrate: - command: "django-admin.py migrate" + command: django-admin.py migrate leader_only: true 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/.gitmodules b/src/.ebignore similarity index 100% rename from .gitmodules rename to src/.ebignore diff --git a/src/analytics/influxdb_wrapper.py b/src/analytics/influxdb_wrapper.py index 561b5fb5f61f..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 @@ -120,6 +121,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 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/settings/common.py b/src/app/settings/common.py index 8f46737ef634..3673b77106f8 100644 --- a/src/app/settings/common.py +++ b/src/app/settings/common.py @@ -10,20 +10,18 @@ https://docs.djangoproject.com/en/1.9/ref/settings/ """ import os +import sys import warnings +from datetime import timedelta from importlib import reload -import environ +import dj_database_url +from environs import Env 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() +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__))) @@ -35,18 +33,20 @@ "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 = 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: @@ -120,6 +118,7 @@ # Third party integrations "integrations.datadog", "integrations.amplitude", + "integrations.sentry", # Rate limiting admin endpoints "axes", ] @@ -129,8 +128,7 @@ SITE_ID = 1 -# Initialise empty databases dict to be populated in environment settings -DATABASES = {} +DATABASES = {"default": dj_database_url.parse(env("DATABASE_URL"), conn_max_age=60)} REST_FRAMEWORK = { "DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticated"], @@ -202,15 +200,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 = ( @@ -245,7 +237,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 +251,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 +267,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 +291,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 = { @@ -319,14 +311,11 @@ }, "loggers": { "django": {"level": "INFO", "handlers": ["console"]}, - "": { - "level": "DEBUG", - "handlers": ["console"], - }, + "": {"level": "DEBUG", "handlers": ["console"]}, }, } -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 +341,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 +393,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)) @@ -413,3 +402,7 @@ "/admin/login/?next=/admin", "/admin/", ] + +# Sentry tracking +SENTRY_SDK_DSN = env("SENTRY_SDK_DSN", default=None) +SENTRY_TRACE_SAMPLE_RATE = env.float("SENTRY_TRACE_SAMPLE_RATE", default=1.0) diff --git a/src/app/settings/develop.py b/src/app/settings/develop.py index 6d441e8c02e3..1c944c2953a0 100644 --- a/src/app/settings/develop.py +++ b/src/app/settings/develop.py @@ -1,12 +1,6 @@ -import os +from app.settings.common import * # noqa -import dj_database_url - -from app.settings.common import * - -DATABASES["default"] = dj_database_url.parse(os.environ["DATABASE_URL"]) - -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 645da02174d3..02441b75e5c0 100644 --- a/src/app/settings/local.py +++ b/src/app/settings/local.py @@ -8,17 +8,4 @@ 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), - } -} - EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" diff --git a/src/app/settings/master.py b/src/app/settings/master.py index 79272f3c17d3..e51aa761358a 100644 --- a/src/app/settings/master.py +++ b/src/app/settings/master.py @@ -1,14 +1,6 @@ -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 +# 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 79272f3c17d3..e51aa761358a 100644 --- a/src/app/settings/staging.py +++ b/src/app/settings/staging.py @@ -1,14 +1,6 @@ -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 +# TODO: remove this in favour of production.py and environment variables LOGGING = { "version": 1, 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 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," 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 = { diff --git a/src/integrations/sentry/__init__.py b/src/integrations/sentry/__init__.py new file mode 100644 index 000000000000..7c1d9e357c9d --- /dev/null +++ b/src/integrations/sentry/__init__.py @@ -0,0 +1 @@ +default_app_config = "integrations.sentry.apps.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, + ) diff --git a/src/organisations/tests/test_views.py b/src/organisations/tests/test_views.py index d846d7985aff..9fcbddba093a 100644 --- a/src/organisations/tests/test_views.py +++ b/src/organisations/tests/test_views.py @@ -502,7 +502,7 @@ def test_when_chargebee_webhook_received_with_unknown_subscription_id_then_404( ) # 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 d18f52767111..0a56991f5873 100644 --- a/src/organisations/views.py +++ b/src/organisations/views.py @@ -194,7 +194,7 @@ def chargebee_webhook(request): % 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": 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..95d6dc77eed8 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,37 +90,36 @@

Users

{% endblock %} - {% block script %} diff --git a/src/sales_dashboard/views.py b/src/sales_dashboard/views.py index 944e2d680c66..36a5fdba7ca7 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): @@ -72,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)), } diff --git a/src/templates/admin/login.html b/src/templates/admin/login.html new file mode 100644 index 000000000000..e69de29bb2d1