diff --git a/Dockerfile b/Dockerfile index 3115c308..78b0b3ac 100644 --- a/Dockerfile +++ b/Dockerfile @@ -51,10 +51,12 @@ COPY --from=backend-build /usr/local/bin/celery /usr/local/bin/celery # Stage 3.2 - Copy source code WORKDIR /app +COPY ./bin/wait_for_db.sh /wait_for_db.sh COPY ./bin/docker_start.sh /start.sh COPY ./bin/celery_worker.sh /celery_worker.sh COPY ./bin/celery_flower.sh /celery_flower.sh COPY ./bin/check_celery_worker_liveness.py ./bin/ +COPY ./bin/setup_configuration.sh /setup_configuration.sh RUN mkdir /app/log /app/config # copy frontend build statics diff --git a/bin/docker_start.sh b/bin/docker_start.sh index dca77e91..3aa32180 100755 --- a/bin/docker_start.sh +++ b/bin/docker_start.sh @@ -15,10 +15,8 @@ uwsgi_threads=${UWSGI_THREADS:-2} mountpoint=${SUBPATH:-/} -until pg_isready; do - >&2 echo "Waiting for database connection..." - sleep 1 -done +# wait for required services +${SCRIPTPATH}/wait_for_db.sh >&2 echo "Database is up." diff --git a/bin/setup_configuration.sh b/bin/setup_configuration.sh new file mode 100755 index 00000000..716b22e8 --- /dev/null +++ b/bin/setup_configuration.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +# setup initial configuration using environment variables +# Run this script from the root of the repository + +#set -e +${SCRIPTPATH}/wait_for_db.sh + +src/manage.py migrate +src/manage.py setup_configuration --no-selftest diff --git a/bin/wait_for_db.sh b/bin/wait_for_db.sh new file mode 100755 index 00000000..89e15e6a --- /dev/null +++ b/bin/wait_for_db.sh @@ -0,0 +1,15 @@ +#!/bin/sh + +set -e + +# Wait for the database container +# See: https://docs.docker.com/compose/startup-order/ +export PGHOST=${DB_HOST:-db} +export PGPORT=${DB_PORT:-5432} + +until pg_isready; do + >&2 echo "Waiting for database connection..." + sleep 1 +done + +>&2 echo "Database is up." diff --git a/docker-compose.yml b/docker-compose.yml index 15511655..16a6df45 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,14 +30,32 @@ services: - CELERY_BROKER_URL=redis://redis:6379/1 - CELERY_RESULT_BACKEND=redis://redis:6379/1 - CELERY_LOGLEVEL=DEBUG + # setup_configuration env vars + - OBJECTS_DOMAIN=web:8000 + - OBJECTS_ORGANIZATION=Objects + - OBJECTTYPES_API_ROOT=https://objecttypes.example.com/api/v2/ + - OBJECTS_OBJECTTYPES_TOKEN=some-random-string + - DEMO_CONFIG_ENABLE=yes + - DEMO_TOKEN=demo-random-string + - DEMO_PERSON=Demo + - DEMO_EMAIL=demo@demo.local ports: - 8000:8000 depends_on: - - db - - redis + web-init: + condition: service_completed_successfully volumes: &web_volumes - media:/app/media # Shared media volume to get access to saved OAS files + web-init: + build: *web_build + environment: *web_env + command: /setup_configuration.sh + depends_on: + - db + - redis + volumes: *web_volumes + celery: build: *web_build environment: *web_env diff --git a/docs/installation/config.rst b/docs/installation/config.rst index 33290aa8..90f32bbd 100644 --- a/docs/installation/config.rst +++ b/docs/installation/config.rst @@ -119,6 +119,14 @@ variables. The user will only be created if it doesn't exist yet. which means the superuser will be created _without_ password. Only has an effect if ``OBJECTS_SUPERUSER_USERNAME`` is set. +Initial configuration +--------------------- + +Both Objects API and Objecttypes API support `setup_configuration` management command, +which allows configuration via environment variables. +All these environment variables are described at :ref:`command line `. + + Specifying the environment variables ===================================== diff --git a/docs/installation/config_cli.rst b/docs/installation/config_cli.rst new file mode 100644 index 00000000..7bed2eb8 --- /dev/null +++ b/docs/installation/config_cli.rst @@ -0,0 +1,137 @@ +.. _installation_config_cli: + + +=================== +Configuration (CLI) +=================== + +After deploying Objecttypes API and Objects API, they need to be configured to be fully functional. The +command line tool `setup_configuration`_ assist with this configuration: + +* It uses environment variables for all configuration choices, therefore you can integrate this with your + infrastructure tooling such as init containers and/or Kubernetes Jobs. +* The command can self-test the configuration to detect problems early on + +You can get the full command documentation with: + +.. code-block:: bash + + src/manage.py setup_configuration --help + +.. warning:: This command is declarative - if configuration is manually changed after + running the command and you then run the exact same command again, the manual + changes will be reverted. + +.. _`setup_configuration`: https://github.com/maykinmedia/django-setup-configuration/ + +Preparation +=========== + +The command executes the list of pluggable configuration steps, and each step +required specific environment variables, that should be prepared. +Here is the description of all available configuration steps and the environment variables, +use by each step for both APIs. + + +Objects API +=========== + +Sites configuration +------------------- + +Configure the domain where Objects API is hosted + +* ``SITES_CONFIG_ENABLE``: enable Site configuration. Defaults to ``True``. +* ``OBJECTS_DOMAIN``: a ``[host]:[port]`` or ``[host]`` value. Required. +* ``OBJECTS_ORGANIZATION``: name of Objects API organization. Required. + +Objecttypes configuration +------------------------- + +Objects API uses Objecttypes API to validate data against JSON schemas, therefore +it should be able to request Objecttypes API. + +* ``OBJECTS_OBJECTTYPES_CONFIG_ENABLE``: enable Objecttypes configuration. Defaults + to ``True``. +* ``OBJECTTYPES_API_ROOT``: full URL to the Objecttypes API root, for example + ``https://objecttypes.gemeente.local/api/v1/``. Required. +* ``OBJECTTYPES_API_OAS``: full URL to the Objecttypes OpenAPI specification. +* ``OBJECTS_OBJECTTYPES_TOKEN``: authorization token. Required. +* ``OBJECTS_OBJECTTYPES_PERSON``: Objects API contact person. Required. +* ``OBJECTS_OBJECTTYPES_EMAIL``: Objects API contact email. Required. + +Demo user configuration +----------------------- + +Demo user can be created to check if Objects API work. It has superuser permissions, +so its creation is not recommended on production environment. + +* ``DEMO_CONFIG_ENABLE``: enable demo user configuration. Defaults to the value of the ``DEBUG`` setting. +* ``DEMO_PERSON``: demo user contact person. Required. +* ``DEMO_EMAIL``: demo user email. Required. +* ``DEMO_TOKEN``: demo token. Required. + + +Objecttypes API +=============== + +ObjectTypes API has similar configuration steps as the Objects API. + +Sites configuration +------------------- + +Configure the domain where Objects API is hosted + +* ``SITES_CONFIG_ENABLE``: enable Site configuration. Defaults to ``True``. +* ``OBJECTTYPES_DOMAIN``: a ``[host]:[port]`` or ``[host]`` value. Required. +* ``OBJECTTYPES_ORGANIZATION``: name of Objecttypes API organization. Required. + +Objects configuration +--------------------- + +Objects API uses Objecttypes API to validate data against JSON schemas, therefore +it should be able to request Objecttypes API. + +* ``OBJECTS_OBJECTTYPES_CONFIG_ENABLE``: enable Objecttypes configuration. Defaults + to ``True``. +* ``OBJECTTYPES_API_ROOT``: full URL to the Objecttypes API root, for example + ``https://objecttypes.gemeente.local/api/v1/``. Required. +* ``OBJECTTYPES_API_OAS``: full URL to the Objecttypes OpenAPI specification. +* ``OBJECTS_OBJECTTYPES_TOKEN``: authorization token. Required. + +Demo user configuration +----------------------- + +The similar configuration as in Objects API. + +* ``DEMO_CONFIG_ENABLE``: enable demo user configuration. Defaults to the value of the ``DEBUG`` setting. +* ``DEMO_PERSON``: demo user contact person. Required. +* ``DEMO_EMAIL``: demo user email. Required. +* ``DEMO_TOKEN``: demo token. Required. + + +Execution +========= + + +With the full command invocation, everything is configured at once and immediately +tested. + +.. code-block:: bash + + src/manage.py setup_configuration + + +You can skip the self-tests by using the ``--no-selftest`` flag. + +.. code-block:: bash + + src/manage.py setup_configuration --no-self-test + + +``setup_configuration`` command checks if the configuration already exists before changing it. +If you want to change some of the values of the existing configuration you can use ``--overwrite`` flag. + +.. code-block:: bash + + src/manage.py setup_configuration --overwrite diff --git a/docs/installation/index.rst b/docs/installation/index.rst index 003200f2..803ec56c 100644 --- a/docs/installation/index.rst +++ b/docs/installation/index.rst @@ -16,5 +16,6 @@ this. quickstart config + config_cli deployment/index oidc diff --git a/requirements/base.in b/requirements/base.in index 454d51ba..dcd9e3aa 100644 --- a/requirements/base.in +++ b/requirements/base.in @@ -3,7 +3,11 @@ open-api-framework # Core python libraries glom # data represenation based on spec jsonschema +furl + +# Django libraries django-log-outgoing-requests +django-setup-configuration # Common ground libraries notifications-api-common diff --git a/requirements/base.txt b/requirements/base.txt index aa655fe0..3118ea4d 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -85,6 +85,7 @@ django==4.2.11 # django-relativedelta # django-rest-framework-condition # django-sendfile2 + # django-setup-configuration # django-simple-certmanager # django-solo # django-two-factor-auth @@ -137,6 +138,8 @@ django-rest-framework-condition==0.1.1 # via commonground-api-common django-sendfile2==0.7.0 # via django-privates +django-setup-configuration==0.1.0 + # via -r requirements/base.in django-simple-certmanager==1.4.1 # via zgw-consumers django-solo==2.2.0 @@ -182,6 +185,8 @@ faker==8.1.0 # via zgw-consumers flower==2.0.1 # via open-api-framework +furl==2.1.3 + # via -r requirements/base.in gemma-zds-client==1.0.1 # via # commonground-api-common @@ -229,6 +234,8 @@ notifications-api-common==0.2.2 # commonground-api-common open-api-framework==0.2.0 # via -r requirements/base.in +orderedmultidict==1.0.1 + # via furl oyaml==1.0 # via commonground-api-common packaging==23.2 @@ -296,7 +303,9 @@ sentry-sdk==1.39.2 six==1.16.0 # via # bleach + # furl # isodate + # orderedmultidict # python-dateutil # qrcode # requests-mock diff --git a/requirements/ci.txt b/requirements/ci.txt index 976fa188..3e5aa95e 100644 --- a/requirements/ci.txt +++ b/requirements/ci.txt @@ -125,6 +125,7 @@ django==4.2.11 # django-relativedelta # django-rest-framework-condition # django-sendfile2 + # django-setup-configuration # django-simple-certmanager # django-solo # django-two-factor-auth @@ -207,6 +208,8 @@ django-sendfile2==0.7.0 # via # -r requirements/base.txt # django-privates +django-setup-configuration==0.1.0 + # via -r requirements/base.txt django-simple-certmanager==1.4.1 # via # -r requirements/base.txt @@ -287,6 +290,8 @@ flower==2.0.1 # open-api-framework freezegun==1.1.0 # via -r requirements/test-tools.in +furl==2.1.3 + # via -r requirements/base.txt gemma-zds-client==1.0.1 # via # -r requirements/base.txt @@ -362,6 +367,10 @@ notifications-api-common==0.2.2 # commonground-api-common open-api-framework==0.2.0 # via -r requirements/base.txt +orderedmultidict==1.0.1 + # via + # -r requirements/base.txt + # furl oyaml==1.0 # via # -r requirements/base.txt @@ -468,7 +477,9 @@ six==1.16.0 # via # -r requirements/base.txt # bleach + # furl # isodate + # orderedmultidict # python-dateutil # qrcode # requests-mock diff --git a/requirements/dev.txt b/requirements/dev.txt index 38add344..bfded5e3 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -147,6 +147,7 @@ django==4.2.11 # django-relativedelta # django-rest-framework-condition # django-sendfile2 + # django-setup-configuration # django-simple-certmanager # django-solo # django-two-factor-auth @@ -233,6 +234,8 @@ django-sendfile2==0.7.0 # via # -r requirements/ci.txt # django-privates +django-setup-configuration==0.1.0 + # via -r requirements/ci.txt django-simple-certmanager==1.4.1 # via # -r requirements/ci.txt @@ -321,6 +324,8 @@ flower==2.0.1 # open-api-framework freezegun==1.1.0 # via -r requirements/ci.txt +furl==2.1.3 + # via -r requirements/ci.txt gemma-zds-client==1.0.1 # via # -r requirements/ci.txt @@ -407,6 +412,10 @@ notifications-api-common==0.2.2 # commonground-api-common open-api-framework==0.2.0 # via -r requirements/ci.txt +orderedmultidict==1.0.1 + # via + # -r requirements/ci.txt + # furl oyaml==1.0 # via # -r requirements/ci.txt @@ -535,7 +544,9 @@ six==1.16.0 # via # -r requirements/ci.txt # bleach + # furl # isodate + # orderedmultidict # python-dateutil # qrcode # requests-mock diff --git a/src/objects/conf/base.py b/src/objects/conf/base.py index 6a1cde85..e9fecf5f 100644 --- a/src/objects/conf/base.py +++ b/src/objects/conf/base.py @@ -111,6 +111,7 @@ "notifications_api_common", "simple_certmanager", "zgw_consumers", + "django_setup_configuration", # Two-factor authentication in the Django admin, enforced. "django_otp", "django_otp.plugins.otp_static", @@ -120,6 +121,7 @@ # Project applications. "objects.accounts", "objects.api", + "objects.config", "objects.core", "objects.token", "objects.utils", @@ -502,3 +504,39 @@ CELERY_TASK_SOFT_TIME_LIMIT = config( "CELERY_TASK_SOFT_TIME_LIMIT", default=5 * 60 ) # soft + +# +# Django setup configuration +# +SETUP_CONFIGURATION_STEPS = [ + "objects.config.site.SiteConfigurationStep", + "objects.config.objecttypes.ObjecttypesStep", + "objects.config.demo.DemoUserStep", +] + + +# +# Objecttypes settings +# + +# setup_configuration command +# sites config +SITES_CONFIG_ENABLE = config("SITES_CONFIG_ENABLE", default=True) +OBJECTS_DOMAIN = config("OBJECTS_DOMAIN", "") +OBJECTS_ORGANIZATION = config("OBJECTS_ORGANIZATION", "") +# objecttypes config +OBJECTS_OBJECTTYPES_CONFIG_ENABLE = config( + "OBJECTS_OBJECTTYPES_CONFIG_ENABLE", default=True +) +OBJECTTYPES_API_ROOT = config("OBJECTTYPES_API_ROOT", "") +if OBJECTTYPES_API_ROOT and not OBJECTTYPES_API_ROOT.endswith("/"): + OBJECTTYPES_API_ROOT = f"{OBJECTTYPES_API_ROOT.strip()}/" +OBJECTTYPES_API_OAS = config( + "OBJECTTYPES_API_OAS", default=f"{OBJECTTYPES_API_ROOT}schema/openapi.yaml" +) +OBJECTS_OBJECTTYPES_TOKEN = config("OBJECTS_OBJECTTYPES_TOKEN", "") +# Demo User Configuration +DEMO_CONFIG_ENABLE = config("DEMO_CONFIG_ENABLE", default=DEBUG) +DEMO_TOKEN = config("DEMO_TOKEN", "") +DEMO_PERSON = config("DEMO_PERSON", "") +DEMO_EMAIL = config("DEMO_EMAIL", "") diff --git a/src/objects/config/__init__.py b/src/objects/config/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/objects/config/demo.py b/src/objects/config/demo.py new file mode 100644 index 00000000..8155717c --- /dev/null +++ b/src/objects/config/demo.py @@ -0,0 +1,56 @@ +from django.conf import settings +from django.urls import reverse + +import requests +from django_setup_configuration.configuration import BaseConfigurationStep +from django_setup_configuration.exceptions import SelfTestFailed + +from objects.token.models import TokenAuth +from objects.utils import build_absolute_url + + +class DemoUserStep(BaseConfigurationStep): + """ + Create demo user to request Objects API + + **NOTE** For now demo user has all permissions. + """ + + verbose_name = "Demo User Configuration" + required_settings = [ + "DEMO_TOKEN", + "DEMO_PERSON", + "DEMO_EMAIL", + ] + enable_setting = "DEMO_CONFIG_ENABLE" + + def is_configured(self) -> bool: + return TokenAuth.objects.filter(token=settings.DEMO_TOKEN).exists() + + def configure(self): + TokenAuth.objects.update_or_create( + token=settings.DEMO_TOKEN, + defaults={ + "contact_person": settings.DEMO_PERSON, + "email": settings.DEMO_EMAIL, + "is_superuser": True, + }, + ) + + def test_configuration(self): + endpoint = reverse("v2:object-list") + full_url = build_absolute_url(endpoint, request=None) + + try: + response = requests.get( + full_url, + headers={ + "Authorization": f"Token {settings.DEMO_TOKEN}", + "Accept": "application/json", + }, + ) + response.raise_for_status() + except requests.RequestException as exc: + raise SelfTestFailed( + "Could not list objects for the configured token" + ) from exc diff --git a/src/objects/config/objecttypes.py b/src/objects/config/objecttypes.py new file mode 100644 index 00000000..1719e21b --- /dev/null +++ b/src/objects/config/objecttypes.py @@ -0,0 +1,54 @@ +from django.conf import settings + +import requests +from django_setup_configuration.configuration import BaseConfigurationStep +from django_setup_configuration.exceptions import SelfTestFailed +from zds_client.client import ClientError +from zgw_consumers.constants import APITypes, AuthTypes +from zgw_consumers.models import Service + + +class ObjecttypesStep(BaseConfigurationStep): + """ + Configure credentials for Objects API to request Objecttypes API + + Normal mode doesn't change the token after its initial creation. + If the token is changed, run this command with 'overwrite' flag + """ + + verbose_name = "Objecttypes Configuration" + required_settings = [ + "OBJECTTYPES_API_ROOT", + "OBJECTS_OBJECTTYPES_TOKEN", + ] + enable_setting = "OBJECTS_OBJECTTYPES_CONFIG_ENABLE" + + def is_configured(self) -> bool: + return Service.objects.filter(api_root=settings.OBJECTTYPES_API_ROOT).exists() + + def configure(self) -> None: + Service.objects.update_or_create( + api_root=settings.OBJECTTYPES_API_ROOT, + defaults={ + "label": "Objecttypes API", + "api_type": APITypes.orc, + "oas": settings.OBJECTTYPES_API_OAS, + "auth_type": AuthTypes.api_key, + "header_key": "Authorization", + "header_value": f"Token {settings.OBJECTS_OBJECTTYPES_TOKEN}", + }, + ) + + def test_configuration(self) -> None: + """ + This check depends on the configuration in Objecttypes + """ + client = Service.objects.get( + api_root=settings.OBJECTTYPES_API_ROOT + ).build_client() + try: + client.list("objecttype") + except (requests.RequestException, ClientError) as exc: + raise SelfTestFailed( + "Could not Could not retrieve list of objecttypes from Objecttypes API." + ) from exc diff --git a/src/objects/config/site.py b/src/objects/config/site.py new file mode 100644 index 00000000..af20fb0e --- /dev/null +++ b/src/objects/config/site.py @@ -0,0 +1,37 @@ +from django.conf import settings +from django.contrib.sites.models import Site +from django.urls import reverse + +import requests +from django_setup_configuration.configuration import BaseConfigurationStep +from django_setup_configuration.exceptions import SelfTestFailed + +from objects.utils import build_absolute_url + + +class SiteConfigurationStep(BaseConfigurationStep): + """ + Configure the application site/domain. + """ + + verbose_name = "Site Configuration" + required_settings = ["OBJECTS_DOMAIN", "OBJECTS_ORGANIZATION"] + enable_setting = "SITES_CONFIG_ENABLE" + + def is_configured(self) -> bool: + site = Site.objects.get_current() + return site.domain == settings.OBJECTS_DOMAIN + + def configure(self): + site = Site.objects.get_current() + site.domain = settings.OBJECTS_DOMAIN + site.name = f"Objects {settings.OBJECTS_ORGANIZATION}".strip() + site.save() + + def test_configuration(self): + full_url = build_absolute_url(reverse("home")) + try: + response = requests.get(full_url) + response.raise_for_status() + except requests.RequestException as exc: + raise SelfTestFailed(f"Could not access home page at '{full_url}'") from exc diff --git a/src/objects/tests/commands/__init__.py b/src/objects/tests/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/objects/tests/commands/test_setup_configuration.py b/src/objects/tests/commands/test_setup_configuration.py new file mode 100644 index 00000000..5a0d06e6 --- /dev/null +++ b/src/objects/tests/commands/test_setup_configuration.py @@ -0,0 +1,117 @@ +from io import StringIO + +from django.contrib.sites.models import Site +from django.core.management import CommandError, call_command +from django.test import TestCase, override_settings +from django.urls import reverse + +import requests_mock +from rest_framework import status +from zgw_consumers.models import Service + +from objects.config.demo import DemoUserStep +from objects.config.objecttypes import ObjecttypesStep +from objects.config.site import SiteConfigurationStep + +from ..utils import mock_service_oas_get + + +@override_settings( + OBJECTS_DOMAIN="objects.example.com", + OBJECTS_ORGANIZATION="ACME", + OBJECTTYPES_API_ROOT="https://objecttypes.example.com/api/v2/", + OBJECTTYPES_API_OAS="https://objecttypes.example.com/api/v2/schema/openapi.yaml", + OBJECTS_OBJECTTYPES_TOKEN="some-random-string", + DEMO_CONFIG_ENABLE=True, + DEMO_TOKEN="demo-random-string", + DEMO_PERSON="Demo", + DEMO_EMAIL="demo@demo.local", +) +class SetupConfigurationTests(TestCase): + def setUp(self): + super().setUp() + + self.addCleanup(Site.objects.clear_cache) + + @requests_mock.Mocker() + def test_setup_configuration(self, m): + stdout = StringIO() + # mocks + m.get("http://objects.example.com/", status_code=200) + m.get("http://objects.example.com/api/v2/objects", json=[]) + mock_service_oas_get( + m, "https://objecttypes.example.com/api/v2/", "objecttypes" + ) + m.get("https://objecttypes.example.com/api/v2/objecttypes", json={}) + + call_command("setup_configuration", stdout=stdout) + + with self.subTest("Command output"): + command_output = stdout.getvalue().splitlines() + expected_output = [ + f"Configuration will be set up with following steps: [{SiteConfigurationStep()}, " + f"{ObjecttypesStep()}, {DemoUserStep()}]", + f"Configuring {SiteConfigurationStep()}...", + f"{SiteConfigurationStep()} is successfully configured", + f"Configuring {ObjecttypesStep()}...", + f"{ObjecttypesStep()} is successfully configured", + f"Configuring {DemoUserStep()}...", + f"{DemoUserStep()} is successfully configured", + "Instance configuration completed.", + ] + + self.assertEqual(command_output, expected_output) + + with self.subTest("Site configured correctly"): + site = Site.objects.get_current() + self.assertEqual(site.domain, "objects.example.com") + self.assertEqual(site.name, "Objects ACME") + + with self.subTest("Objects can query Objecttypes API"): + client = Service.get_client("https://objecttypes.example.com/api/v2/") + self.assertIsNotNone(client) + + client.list("objecttype") + + list_call = m.last_request + self.assertEqual( + list_call.url, "https://objecttypes.example.com/api/v2/objecttypes" + ) + self.assertIn("Authorization", list_call.headers) + self.assertEqual( + list_call.headers["Authorization"], "Token some-random-string" + ) + + with self.subTest("Demo user configured correctly"): + response = self.client.get( + reverse("v2:object-list"), + HTTP_AUTHORIZATION="Token demo-random-string", + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + + @requests_mock.Mocker() + def test_setup_configuration_selftest_fails(self, m): + m.get("http://objects.example.com/", status_code=500) + m.get("http://objects.example.com/api/v2/objects", status_code=200) + mock_service_oas_get( + m, "https://objecttypes.example.com/api/v2/", "objecttypes" + ) + m.get("https://objecttypes.example.com/api/v2/objecttypes", json={}) + + with self.assertRaisesMessage( + CommandError, + "Configuration test failed with errors: " + "Site Configuration: Could not access home page at 'http://objects.example.com/'", + ): + call_command("setup_configuration") + + @requests_mock.Mocker() + def test_setup_configuration_without_selftest(self, m): + stdout = StringIO() + + call_command("setup_configuration", no_selftest=True, stdout=stdout) + command_output = stdout.getvalue() + + self.assertEqual(len(m.request_history), 0) + self.assertTrue("Selftest is skipped" in command_output) diff --git a/src/objects/tests/config/__init__.py b/src/objects/tests/config/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/objects/tests/config/test_demo_configuration.py b/src/objects/tests/config/test_demo_configuration.py new file mode 100644 index 00000000..5d9a2cce --- /dev/null +++ b/src/objects/tests/config/test_demo_configuration.py @@ -0,0 +1,73 @@ +from unittest.mock import patch + +from django.test import TestCase, override_settings + +import requests +import requests_mock +from django_setup_configuration.exceptions import SelfTestFailed + +from objects.config.demo import DemoUserStep +from objects.token.models import TokenAuth + + +@override_settings( + DEMO_TOKEN="demo-random-string", DEMO_PERSON="Demo", DEMO_EMAIL="demo@demo.local" +) +class DemoConfigurationTests(TestCase): + def test_configure(self): + configuration = DemoUserStep() + + configuration.configure() + + token_auth = TokenAuth.objects.get() + self.assertEqual(token_auth.token, "demo-random-string") + self.assertTrue(token_auth.is_superuser) + self.assertEqual(token_auth.contact_person, "Demo") + self.assertEqual(token_auth.email, "demo@demo.local") + + @requests_mock.Mocker() + @patch( + "objects.config.demo.build_absolute_url", + return_value="http://testserver/objects", + ) + def test_configuration_check_ok(self, m, *mocks): + configuration = DemoUserStep() + configuration.configure() + m.get("http://testserver/objects", json=[]) + + configuration.test_configuration() + + self.assertEqual(m.last_request.url, "http://testserver/objects") + self.assertEqual(m.last_request.method, "GET") + + @requests_mock.Mocker() + @patch( + "objects.config.demo.build_absolute_url", + return_value="http://testserver/objects", + ) + def test_configuration_check_failures(self, m, *mocks): + configuration = DemoUserStep() + configuration.configure() + + mock_kwargs = ( + {"exc": requests.ConnectTimeout}, + {"exc": requests.ConnectionError}, + {"status_code": 404}, + {"status_code": 403}, + {"status_code": 500}, + ) + for mock_config in mock_kwargs: + with self.subTest(mock=mock_config): + m.get("http://testserver/objects", **mock_config) + + with self.assertRaises(SelfTestFailed): + configuration.test_configuration() + + def test_is_configured(self): + configuration = DemoUserStep() + + self.assertFalse(configuration.is_configured()) + + configuration.configure() + + self.assertTrue(configuration.is_configured()) diff --git a/src/objects/tests/config/test_objecttypes_configuration.py b/src/objects/tests/config/test_objecttypes_configuration.py new file mode 100644 index 00000000..95fee463 --- /dev/null +++ b/src/objects/tests/config/test_objecttypes_configuration.py @@ -0,0 +1,82 @@ +from unittest.mock import patch + +from django.test import TestCase, override_settings + +import requests +import requests_mock +from django_setup_configuration.exceptions import SelfTestFailed +from zgw_consumers.constants import AuthTypes +from zgw_consumers.models import Service + +from objects.config.objecttypes import ObjecttypesStep + +from ..utils import mock_service_oas_get + + +@override_settings( + OBJECTTYPES_API_ROOT="https://objecttypes.example.com/api/v2/", + OBJECTTYPES_API_OAS="https://objecttypes.example.com/api/v2/schema/openapi.yaml", + OBJECTS_OBJECTTYPES_TOKEN="some-random-string", +) +class ObjecttypesConfigurationTests(TestCase): + def test_configure(self): + configuration = ObjecttypesStep() + + configuration.configure() + + service = Service.objects.get( + api_root="https://objecttypes.example.com/api/v2/" + ) + self.assertEqual( + service.oas, "https://objecttypes.example.com/api/v2/schema/openapi.yaml" + ) + self.assertEqual(service.auth_type, AuthTypes.api_key) + self.assertEqual(service.header_key, "Authorization") + self.assertEqual(service.header_value, "Token some-random-string") + + @requests_mock.Mocker() + def test_selftest_ok(self, m): + configuration = ObjecttypesStep() + configuration.configure() + mock_service_oas_get( + m, "https://objecttypes.example.com/api/v2/", "objecttypes" + ) + m.get("https://objecttypes.example.com/api/v2/objecttypes", json={}) + + configuration.test_configuration() + + self.assertEqual( + m.last_request.url, "https://objecttypes.example.com/api/v2/objecttypes" + ) + + @requests_mock.Mocker() + def test_selftest_fail(self, m): + configuration = ObjecttypesStep() + configuration.configure() + mock_service_oas_get( + m, "https://objecttypes.example.com/api/v2/", "objecttypes" + ) + + mock_kwargs = ( + {"exc": requests.ConnectTimeout}, + {"exc": requests.ConnectionError}, + {"status_code": 404}, + {"status_code": 403}, + {"status_code": 500}, + ) + for mock_config in mock_kwargs: + with self.subTest(mock=mock_config): + m.get( + "https://objecttypes.example.com/api/v2/objecttypes", **mock_config + ) + + with self.assertRaises(SelfTestFailed): + configuration.test_configuration() + + def test_is_configured(self): + configuration = ObjecttypesStep() + self.assertFalse(configuration.is_configured()) + + configuration.configure() + + self.assertTrue(configuration.is_configured()) diff --git a/src/objects/tests/config/test_site_configuration.py b/src/objects/tests/config/test_site_configuration.py new file mode 100644 index 00000000..b6e47ecd --- /dev/null +++ b/src/objects/tests/config/test_site_configuration.py @@ -0,0 +1,66 @@ +from django.contrib.sites.models import Site +from django.test import TestCase, override_settings + +import requests +import requests_mock +from django_setup_configuration.exceptions import SelfTestFailed + +from objects.config.site import SiteConfigurationStep + + +@override_settings( + OBJECTS_DOMAIN="localhost:8000", + OBJECTS_ORGANIZATION="ACME", +) +class SiteConfigurationTests(TestCase): + def setUp(self): + super().setUp() + + self.addCleanup(Site.objects.clear_cache) + + def test_set_domain(self): + configuration = SiteConfigurationStep() + configuration.configure() + + site = Site.objects.get_current() + self.assertEqual(site.domain, "localhost:8000") + self.assertEqual(site.name, "Objects ACME") + + @requests_mock.Mocker() + def test_configuration_check_ok(self, m): + m.get("http://localhost:8000/", status_code=200) + configuration = SiteConfigurationStep() + configuration.configure() + + configuration.test_configuration() + + self.assertEqual(m.last_request.url, "http://localhost:8000/") + self.assertEqual(m.last_request.method, "GET") + + @requests_mock.Mocker() + def test_configuration_check_failures(self, m): + configuration = SiteConfigurationStep() + configuration.configure() + + mock_kwargs = ( + {"exc": requests.ConnectTimeout}, + {"exc": requests.ConnectionError}, + {"status_code": 404}, + {"status_code": 403}, + {"status_code": 500}, + ) + for mock_config in mock_kwargs: + with self.subTest(mock=mock_config): + m.get("http://localhost:8000/", **mock_config) + + with self.assertRaises(SelfTestFailed): + configuration.test_configuration() + + def test_is_configured(self): + configuration = SiteConfigurationStep() + + self.assertFalse(configuration.is_configured()) + + configuration.configure() + + self.assertTrue(configuration.is_configured()) diff --git a/src/objects/urls.py b/src/objects/urls.py index 93bd2f34..23c5f9a2 100644 --- a/src/objects/urls.py +++ b/src/objects/urls.py @@ -48,6 +48,7 @@ template_name="index.html", extra_context={"version": api_settings.DEFAULT_VERSION}, ), + name="home", ), path("ref/", include("vng_api_common.urls")), path("ref/", include("notifications_api_common.urls")), diff --git a/src/objects/utils/__init__.py b/src/objects/utils/__init__.py index e69de29b..ab878fd7 100644 --- a/src/objects/utils/__init__.py +++ b/src/objects/utils/__init__.py @@ -0,0 +1,29 @@ +from django.conf import settings +from django.http import HttpRequest + +from furl import furl + + +def get_domain() -> str: + """ + Obtain the domain/netloc according to settings or configuration. + """ + from django.contrib.sites.models import Site + + if settings.OBJECTS_DOMAIN: + return settings.OBJECTS_DOMAIN + + return Site.objects.get_current().domain + + +def build_absolute_url(path: str, request: HttpRequest | None = None) -> str: + if request is not None: + return request.build_absolute_uri(path) + + domain = get_domain() + _furl = furl( + scheme="https" if settings.IS_HTTPS else "http", + netloc=domain, + path=path, + ) + return _furl.url