diff --git a/docs/installation/config.rst b/docs/installation/config.rst index c88b83ad..ae1d0d80 100644 --- a/docs/installation/config.rst +++ b/docs/installation/config.rst @@ -57,9 +57,12 @@ can be configured by setting the following environment variables Other settings -------------- +* ``ENVIRONMENT``: An identifier for the environment, displayed in the admin depending on + the settings module used and included in the error monitoring (see ``SENTRY_DSN``). + The default is set according to ``DJANGO_SETTINGS_MODULE``. Good examples values are: -* ``ADMINS``: Comma seperated list (without spaces!) of e-mail addresses to - sent an email in the case of any errors. Defaults to an empty list. + * ``production`` + * ``test`` * ``SITE_ID``: The database ID of the site object. Defaults to ``1``. diff --git a/src/objects/conf/base.py b/src/objects/conf/base.py index c9405391..400f795a 100644 --- a/src/objects/conf/base.py +++ b/src/objects/conf/base.py @@ -6,10 +6,11 @@ from sentry_sdk.integrations import django, redis from .api import * # noqa +from .utils import config try: from sentry_sdk.integrations import celery -except Exception: # no celery in this proejct +except Exception: # no celery in this project celery = None # Build paths inside the project like this: os.path.join(BASE_DIR, ...) @@ -20,27 +21,28 @@ os.path.join(DJANGO_PROJECT_DIR, os.path.pardir, os.path.pardir) ) -# Quick-start development settings - unsuitable for production -# See https://docs.djangoproject.com/en/3.0/howto/deployment/checklist/ +# +# Core Django settings +# +SITE_ID = config("SITE_ID", 1) # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = os.getenv("SECRET_KEY") +SECRET_KEY = config("SECRET_KEY") # SECURITY WARNING: don't run with debug turned on in production! -DEBUG = False - -IS_HTTPS = os.getenv("IS_HTTPS", not DEBUG) +DEBUG = config("DEBUG", default=False) -ALLOWED_HOSTS = [] +IS_HTTPS = config("IS_HTTPS", not DEBUG) +ALLOWED_HOSTS = config("ALLOWED_HOSTS", default="", split=True) DATABASES = { "default": { "ENGINE": "django.contrib.gis.db.backends.postgis", - "NAME": os.getenv("DB_NAME", "objects"), - "USER": os.getenv("DB_USER", "objects"), - "PASSWORD": os.getenv("DB_PASSWORD", "objects"), - "HOST": os.getenv("DB_HOST", "localhost"), - "PORT": os.getenv("DB_PORT", 5432), + "NAME": config("DB_NAME", "objects"), + "USER": config("DB_USER", "objects"), + "PASSWORD": config("DB_PASSWORD", "objects"), + "HOST": config("DB_HOST", "localhost"), + "PORT": config("DB_PORT", 5432), } } @@ -307,12 +309,19 @@ LOGIN_REDIRECT_URL = reverse_lazy("admin:index") LOGOUT_REDIRECT_URL = reverse_lazy("admin:index") +# +# SECURITY settings +# +SESSION_COOKIE_SECURE = IS_HTTPS +SESSION_COOKIE_HTTPONLY = True +CSRF_COOKIE_SECURE = IS_HTTPS + # # Custom settings # PROJECT_NAME = "Objects" SITE_TITLE = "Starting point" -ENVIRONMENT = None +ENVIRONMENT = config("ENVIRONMENT", "") SHOW_ALERT = True # @@ -369,7 +378,7 @@ HIJACK_ALLOW_GET_REQUESTS = True # Sentry SDK -SENTRY_DSN = os.getenv("SENTRY_DSN") +SENTRY_DSN = config("SENTRY_DSN", None) SENTRY_SDK_INTEGRATIONS = [ django.DjangoIntegration(), @@ -383,7 +392,8 @@ SENTRY_CONFIG = { "dsn": SENTRY_DSN, - "release": os.getenv("VERSION_TAG", "VERSION_TAG not set"), + "release": config("VERSION_TAG", "VERSION_TAG not set"), + "environment": ENVIRONMENT, } sentry_sdk.init( @@ -393,10 +403,10 @@ # # Elastic APM # -ELASTIC_APM_SERVER_URL = os.getenv("ELASTIC_APM_SERVER_URL", None) +ELASTIC_APM_SERVER_URL = config("ELASTIC_APM_SERVER_URL", None) ELASTIC_APM = { - "SERVICE_NAME": os.getenv("ELASTIC_APM_SERVICE_NAME", "Objects API"), - "SECRET_TOKEN": os.getenv("ELASTIC_APM_SECRET_TOKEN", "default"), + "SERVICE_NAME": config("ELASTIC_APM_SERVICE_NAME", "Objects API"), + "SECRET_TOKEN": config("ELASTIC_APM_SECRET_TOKEN", "default"), "SERVER_URL": ELASTIC_APM_SERVER_URL, "ENABLED": bool(ELASTIC_APM_SERVER_URL), } @@ -406,32 +416,19 @@ "elasticapm.contrib.django", ] -SITE_ID = os.getenv("SITE_ID", 1) # VNG API Common CUSTOM_CLIENT_FETCHER = "objects.utils.client.get_client" # settings for sending notifications NOTIFICATIONS_KANAAL = "objecten" -NOTIFICATIONS_DISABLED = os.getenv("NOTIFICATIONS_DISABLED", False) in [ - "True", - "true", - "yes", -] +NOTIFICATIONS_DISABLED = config("NOTIFICATIONS_DISABLED", False) # # Maykin fork of DJANGO-TWO-FACTOR-AUTH # -TWO_FACTOR_FORCE_OTP_ADMIN = os.getenv("TWO_FACTOR_FORCE_OTP_ADMIN", "True") in [ - "True", - "true", - "yes", -] -TWO_FACTOR_PATCH_ADMIN = os.getenv("TWO_FACTOR_PATCH_ADMIN", "True") in [ - "True", - "true", - "yes", -] +TWO_FACTOR_FORCE_OTP_ADMIN = config("TWO_FACTOR_FORCE_OTP_ADMIN", not DEBUG) +TWO_FACTOR_PATCH_ADMIN = config("TWO_FACTOR_PATCH_ADMIN", True) # # Mozilla Django OIDC DB settings diff --git a/src/objects/conf/ci.py b/src/objects/conf/ci.py index 7a9eef3c..23946766 100644 --- a/src/objects/conf/ci.py +++ b/src/objects/conf/ci.py @@ -5,6 +5,8 @@ import os os.environ.setdefault("SECRET_KEY", "dummy") +os.environ.setdefault("IS_HTTPS", "no") +os.environ.setdefault("ENVIRONMENT", "ci") from .base import * # noqa isort:skip @@ -20,7 +22,6 @@ LOGGING = None # Quiet is nice logging.disable(logging.CRITICAL) -ENVIRONMENT = "ci" # # Django-axes @@ -28,7 +29,6 @@ AXES_BEHIND_REVERSE_PROXY = False NOTIFICATIONS_DISABLED = True -IS_HTTPS = False # diff --git a/src/objects/conf/dev.py b/src/objects/conf/dev.py index cc07623d..f06e07ec 100644 --- a/src/objects/conf/dev.py +++ b/src/objects/conf/dev.py @@ -2,36 +2,26 @@ import sys import warnings +os.environ.setdefault("DEBUG", "yes") +os.environ.setdefault("ALLOWED_HOSTS", "*") os.environ.setdefault( "SECRET_KEY", "2(@f(-6s_u(5fd&1sg^uvu2s(c-9sapw)1era8q&)g)h@cwxxg" ) +os.environ.setdefault("IS_HTTPS", "no") +os.environ.setdefault("ENVIRONMENT", "development") -# uses postgresql by default, see base.py os.environ.setdefault("DB_NAME", "objects"), os.environ.setdefault("DB_USER", "objects"), os.environ.setdefault("DB_PASSWORD", "objects"), from .base import * # noqa isort:skip -# Feel free to switch dev to sqlite3 for simple projects, -# or override DATABASES in your local.py -# DATABASES['default']['ENGINE'] = 'django.db.backends.sqlite3' - # # Standard Django settings. # -DEBUG = True -IS_HTTPS = False EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" -ADMINS = () -MANAGERS = ADMINS - -# Hosts/domain names that are valid for this site; required if DEBUG is False -# See https://docs.djangoproject.com/en/1.5/ref/settings/#allowed-hosts -ALLOWED_HOSTS = ["localhost", "127.0.0.1"] - LOGGING["loggers"].update( { "objects": { @@ -66,19 +56,6 @@ } ) -# -# Additional Django settings -# - -# Disable security measures for development -SESSION_COOKIE_SECURE = False -SESSION_COOKIE_HTTPONLY = False -CSRF_COOKIE_SECURE = False - -# -# Custom settings -# -ENVIRONMENT = "development" # # Library settings diff --git a/src/objects/conf/docker.py b/src/objects/conf/docker.py index b7981d17..9207c109 100644 --- a/src/objects/conf/docker.py +++ b/src/objects/conf/docker.py @@ -1,39 +1,17 @@ import os -from django.core.exceptions import ImproperlyConfigured - os.environ.setdefault("DB_USER", os.getenv("DB_USER", "objects")) os.environ.setdefault("DB_NAME", os.getenv("DB_NAME", "objects")) os.environ.setdefault("DB_PASSWORD", os.getenv("DB_PASSWORD", "objects")) os.environ.setdefault("DB_HOST", os.getenv("DB_HOST", "db")) +os.environ.setdefault("ENVIRONMENT", "docker") from .base import * # noqa isort:skip - -# Helper function -missing_environment_vars = [] - - -def getenv(key, default=None, required=False, split=False): - val = os.getenv(key, default) - if required and val is None: - missing_environment_vars.append(key) - if split and val: - val = val.split(",") - return val - +from .utils import config # noqa isort:skip # # Standard Django settings. # -DEBUG = getenv("DEBUG", False) - -ADMINS = getenv("ADMINS", split=True) -MANAGERS = ADMINS - -# Hosts/domain names that are valid for this site; required if DEBUG is False -# See https://docs.djangoproject.com/en/1.5/ref/settings/#allowed-hosts -ALLOWED_HOSTS = getenv("ALLOWED_HOSTS", "*", split=True) - CACHES = { "default": { "BACKEND": "django.core.cache.backends.locmem.LocMemCache", @@ -46,7 +24,7 @@ def getenv(key, default=None, required=False, split=False): } # Deal with being hosted on a subpath -subpath = getenv("SUBPATH") +subpath = config("SUBPATH", None) if subpath: if not subpath.startswith("/"): subpath = f"/{subpath}" @@ -55,52 +33,6 @@ def getenv(key, default=None, required=False, split=False): STATIC_URL = f"{FORCE_SCRIPT_NAME}{STATIC_URL}" MEDIA_URL = f"{FORCE_SCRIPT_NAME}{MEDIA_URL}" -# See: docker-compose.yml -# Optional Docker container usage below: -# -# # Elasticsearch -# HAYSTACK_CONNECTIONS = { -# 'default': { -# 'ENGINE': 'haystack.backends.elasticsearch2_backend.Elasticsearch2SearchEngine', -# 'URL': getenv('ELASTICSEARCH_URL', 'http://elasticsearch:9200/'), -# 'INDEX_NAME': 'objects', -# }, -# } -# -# # Caching -# CACHES = { -# 'default': { -# 'BACKEND': 'django_redis.cache.RedisCache', -# 'LOCATION': getenv('CACHE_LOCATION', 'redis://redis:6379/1'), -# 'OPTIONS': { -# 'CLIENT_CLASS': 'django_redis.client.DefaultClient', -# 'IGNORE_EXCEPTIONS': True, -# } -# } -# } - -# -# Additional Django settings -# - -# Disable security measures for development -SESSION_COOKIE_SECURE = getenv("SESSION_COOKIE_SECURE", False) -SESSION_COOKIE_HTTPONLY = getenv("SESSION_COOKIE_HTTPONLY", False) -CSRF_COOKIE_SECURE = getenv("CSRF_COOKIE_SECURE", False) - -# -# Custom settings -# -ENVIRONMENT = "docker" - -ELASTIC_APM["SERVICE_NAME"] += " " + ENVIRONMENT - -if missing_environment_vars: - raise ImproperlyConfigured( - "These environment variables are required but missing: {}".format( - ", ".join(missing_environment_vars) - ) - ) # # Library settings @@ -109,3 +41,6 @@ def getenv(key, default=None, required=False, split=False): # django-axes AXES_BEHIND_REVERSE_PROXY = False AXES_CACHE = "axes_cache" + +# Elastic APM +ELASTIC_APM["SERVICE_NAME"] += " " + ENVIRONMENT diff --git a/src/objects/conf/tests/__init__.py b/src/objects/conf/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/objects/conf/tests/test_config_helper.py b/src/objects/conf/tests/test_config_helper.py new file mode 100644 index 00000000..198e684c --- /dev/null +++ b/src/objects/conf/tests/test_config_helper.py @@ -0,0 +1,15 @@ +from django.test import SimpleTestCase + +from ..utils import config + + +class ConfigHelperTests(SimpleTestCase): + def test_empty_list_as_default(self): + value = config("SOME_TEST_ENVVAR", split=True, default=[]) + + self.assertEqual(value, []) + + def test_non_empty_list_as_default(self): + value = config("SOME_TEST_ENVVAR", split=True, default=["foo"]) + + self.assertEqual(value, ["foo"]) diff --git a/src/objects/conf/utils.py b/src/objects/conf/utils.py new file mode 100644 index 00000000..86dd7cb7 --- /dev/null +++ b/src/objects/conf/utils.py @@ -0,0 +1,26 @@ +import logging + +from decouple import Csv, config as _config, undefined + +logger = logging.getLogger(__name__) + + +def config(option: str, default=undefined, *args, **kwargs): + """ + Pull a config parameter from the environment. + + Read the config variable ``option``. If it's optional, use the ``default`` value. + Input is automatically cast to the correct type, where the type is derived from the + default value if possible. + + Pass ``split=True`` to split the comma-separated input into a list. + """ + if "split" in kwargs: + kwargs.pop("split") + kwargs["cast"] = Csv() + if isinstance(default, list): + default = ",".join(default) + + if default is not undefined and default is not None: + kwargs.setdefault("cast", type(default)) + return _config(option, default=default, *args, **kwargs)