From de81826568c3ae785dd0fb97b84c04c53c620639 Mon Sep 17 00:00:00 2001 From: Anna Shamray Date: Fri, 9 Feb 2024 17:22:03 +0100 Subject: [PATCH 01/14] :sparkles: [#1] first draft of setup_notification command --- .github/workflows/ci.yml | 2 +- django_setup_configuration/configuration.py | 70 ++++++++++++++ django_setup_configuration/exceptions.py | 22 +++++ .../management/__init__.py | 0 .../management/commands/__init__.py | 0 .../commands/setup_configuration.py | 96 +++++++++++++++++++ pyproject.toml | 4 +- 7 files changed, 191 insertions(+), 3 deletions(-) create mode 100644 django_setup_configuration/configuration.py create mode 100644 django_setup_configuration/exceptions.py create mode 100644 django_setup_configuration/management/__init__.py create mode 100644 django_setup_configuration/management/commands/__init__.py create mode 100644 django_setup_configuration/management/commands/setup_configuration.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b0d7b7a..dda1adf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: strategy: matrix: python: ['3.10', '3.11', '3.12'] - django: ['4.2'] + django: ['3.2'] name: Run the test suite (Python $, Django $) diff --git a/django_setup_configuration/configuration.py b/django_setup_configuration/configuration.py new file mode 100644 index 0000000..e5dd55e --- /dev/null +++ b/django_setup_configuration/configuration.py @@ -0,0 +1,70 @@ +import logging +from abc import ABC, abstractmethod + +from django.conf import settings + +from .exceptions import PrerequisiteFailed + +logger = logging.getLogger(__name__) + + +class BaseConfigurationStep(ABC): + verbose_name: str + required_settings: list[str] = [] + enable_setting: str = "" + + def __str__(self): + return self.verbose_name + + def validate_requirements(self) -> None: + """ + check prerequisites of the configuration + + :raises: :class: `django_setup_configuration.exceptions.PrerequisiteFailed` + if prerequisites are missing + """ + missing = [ + var for var in self.required_settings if not getattr(settings, var, None) + ] + if missing: + raise PrerequisiteFailed( + f"{', '.join(missing)} settings should be provided" + ) + + def is_enabled(self) -> bool: + """ + Hook to switch on and off the configuration step from env vars + + By default all steps are enabled + """ + if not self.enable_setting: + return True + + return getattr(settings, self.enable_setting, True) + + @abstractmethod + def is_configured(self) -> bool: + """ + Check that the configuration is already done with current env variables + """ + ... + + @abstractmethod + def configure(self) -> None: + """ + Run the configuration step. + + :raises: :class: `django_setup_configuration.exceptions.ConfigurationRunFailed` + if the configuration has an error + """ + ... + + @abstractmethod + def test_configuration(self) -> None: + """ + Test that the configuration works as expected + + :raises: :class:`openzaak.config.bootstrap.exceptions.SelfTestFailure` + if the configuration aspect was found to be faulty. + """ + ... diff --git a/django_setup_configuration/exceptions.py b/django_setup_configuration/exceptions.py new file mode 100644 index 0000000..7cd5c1a --- /dev/null +++ b/django_setup_configuration/exceptions.py @@ -0,0 +1,22 @@ +class ConfigurationException(Exception): + """ + Base exception for configuration steps + """ + + +class PrerequisiteFailed(ConfigurationException): + """ + Raise an error then configuration step can't be started + """ + + +class ConfigurationRunFailed(ConfigurationException): + """ + Raise an error then configuration process was faulty + """ + + +class SelfTestFailed(ConfigurationException): + """ + Raise an error for failed configuration self-tests. + """ diff --git a/django_setup_configuration/management/__init__.py b/django_setup_configuration/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/django_setup_configuration/management/commands/__init__.py b/django_setup_configuration/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/django_setup_configuration/management/commands/setup_configuration.py b/django_setup_configuration/management/commands/setup_configuration.py new file mode 100644 index 0000000..648192e --- /dev/null +++ b/django_setup_configuration/management/commands/setup_configuration.py @@ -0,0 +1,96 @@ +from collections import OrderedDict + +from django.conf import settings +from django.core.management import BaseCommand, CommandError +from django.utils.module_loading import import_string + +from ...configuration import BaseConfigurationStep +from ...exceptions import ConfigurationRunFailed, PrerequisiteFailed, SelfTestFailed + + +class ErrorDict(OrderedDict): + """ + small helper to display errors + """ + + def as_text(self) -> str: + output = [f"{k}: {v}" for k, v in self.items()] + return "\n".join(output) + + +class Command(BaseCommand): + help = ( + "Bootstrap the initial Open Zaak configuration. " + "This command is run only in non-interactive mode with settings " + "configured mainly via environment variables." + ) + output_transaction = True + + def add_arguments(self, parser): + parser.add_argument( + "--overwrite", + action="store_true", + help=( + "Overwrite the existing configuration. Should be used if some " + "of the env variables have been changed." + ), + ) + + def handle(self, **options): + overwrite: bool = options["overwrite"] + + # todo transaction atomic + errors = ErrorDict() + steps: list[BaseConfigurationStep] = [ + import_string(path) for path in settings.SETUP_CONFIGURATION_STEPS + ] + enabled_steps = [step for step in steps if step.is_enabled()] + + self.stdout.write( + f"Configuration would be set up with following steps: {enabled_steps}" + ) + + # 1. Check prerequisites of all steps + for step in enabled_steps: + try: + step.validate_requirements() + except PrerequisiteFailed as exc: + errors[step] = exc + + if errors: + raise CommandError( + f"Prerequisites for configuration are not fulfilled: {errors.as_text()}" + ) + + # 2. Configure steps + configured_steps = [] + for step in enabled_steps: + if not overwrite and step.is_configured(): + self.stdout.write( + f"Step {step} is skipped, because the configuration already exists." + ) + continue + else: + self.stdout.write(f"Configuring {step}...") + try: + step.configure() + except ConfigurationRunFailed as exc: + raise CommandError(f"Could not configure step {step}") from exc + else: + self.stdout.write(f"{step} is successfully configured") + configured_steps.append(step) + + # 3. Test configuration + for step in configured_steps: + # todo global env to turn off self tests? + try: + step.test_configuration() + except SelfTestFailed as exc: + errors[step] = exc + + if errors: + raise CommandError( + f"Configuration test failed with errors: {errors.as_text()}" + ) + + self.stdout.write(self.style.SUCCESS("Instance configuration completed.")) diff --git a/pyproject.toml b/pyproject.toml index 22a369d..df63bf1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,7 @@ keywords = ["TODO"] classifiers = [ "Development Status :: 3 - Alpha", "Framework :: Django", - "Framework :: Django :: 4.2", + "Framework :: Django :: 3.2", "Intended Audience :: Developers", "Operating System :: Unix", "Operating System :: MacOS", @@ -27,7 +27,7 @@ classifiers = [ ] requires-python = ">=3.10" dependencies = [ - "django>=4.2" + "django>=3.2" ] [project.urls] From a68eabb0b6730019a3ad5351c96a88dd5ce25731 Mon Sep 17 00:00:00 2001 From: Anna Shamray Date: Fri, 9 Feb 2024 17:34:50 +0100 Subject: [PATCH 02/14] :green_heart: [#1] fix gh actions --- .github/workflows/ci.yml | 8 ++++---- .github/workflows/code_quality.yml | 2 +- tox.ini | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dda1adf..3a08799 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,7 +24,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: - python-version: $ + python-version: ${{ matrix.python }} - name: Install dependencies run: pip install tox tox-gh-actions @@ -32,8 +32,8 @@ jobs: - name: Run tests run: tox env: - PYTHON_VERSION: $ - DJANGO: $ + PYTHON_VERSION: ${{ matrix.python }} + DJANGO: ${{ matrix.django }} - name: Publish coverage report uses: codecov/codecov-action@v3 @@ -61,4 +61,4 @@ jobs: uses: pypa/gh-action-pypi-publish@release/v1 with: user: __token__ - password: $ + password: ${{ secrets.PYPI_TOKEN }} diff --git a/.github/workflows/code_quality.yml b/.github/workflows/code_quality.yml index 102c704..0433b75 100644 --- a/.github/workflows/code_quality.yml +++ b/.github/workflows/code_quality.yml @@ -30,4 +30,4 @@ jobs: run: pip install tox - run: tox env: - TOXENV: $ + TOXENV: ${{ matrix.toxenv }} diff --git a/tox.ini b/tox.ini index a3488f8..7b4b71a 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] envlist = - py{310,311,312}-django{42} + py{310,311,312}-django{32} isort black flake8 @@ -15,7 +15,7 @@ python = [gh-actions:env] DJANGO = - 4.2: django42 + 3.2: django32 [testenv] setenv = @@ -25,7 +25,7 @@ extras = tests coverage deps = - django42: Django~=4.2.0 + django32: Django~=3.2.0 commands = py.test tests \ --cov --cov-report xml:reports/coverage-{envname}.xml \ From fdba99b49c0d1ac7994be43dcf10388de0eaa5e5 Mon Sep 17 00:00:00 2001 From: Anna Shamray Date: Fri, 9 Feb 2024 17:40:04 +0100 Subject: [PATCH 03/14] :green_heart: [#1] fix doc build --- docs/index.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/index.rst b/docs/index.rst index 347ecec..a9b3572 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -3,7 +3,7 @@ contain the root `toctree` directive. Welcome to django_setup_configuration's documentation! -================================================= +====================================================== |build-status| |code-quality| |black| |coverage| |docs| From 3ac524b1a99791b2877176f25e272c0680d2d712 Mon Sep 17 00:00:00 2001 From: Anna Shamray Date: Tue, 20 Feb 2024 15:12:23 +0100 Subject: [PATCH 04/14] :bug: [#1] fix management command --- django_setup_configuration/configuration.py | 2 +- .../management/commands/setup_configuration.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/django_setup_configuration/configuration.py b/django_setup_configuration/configuration.py index e5dd55e..e75d422 100644 --- a/django_setup_configuration/configuration.py +++ b/django_setup_configuration/configuration.py @@ -13,7 +13,7 @@ class BaseConfigurationStep(ABC): required_settings: list[str] = [] enable_setting: str = "" - def __str__(self): + def __repr__(self): return self.verbose_name def validate_requirements(self) -> None: diff --git a/django_setup_configuration/management/commands/setup_configuration.py b/django_setup_configuration/management/commands/setup_configuration.py index 648192e..91bb48f 100644 --- a/django_setup_configuration/management/commands/setup_configuration.py +++ b/django_setup_configuration/management/commands/setup_configuration.py @@ -42,7 +42,7 @@ def handle(self, **options): # todo transaction atomic errors = ErrorDict() steps: list[BaseConfigurationStep] = [ - import_string(path) for path in settings.SETUP_CONFIGURATION_STEPS + import_string(path)() for path in settings.SETUP_CONFIGURATION_STEPS ] enabled_steps = [step for step in steps if step.is_enabled()] From 261a73ee2acada73bf3decd18c40be01b998b0f5 Mon Sep 17 00:00:00 2001 From: Anna Shamray Date: Tue, 20 Feb 2024 15:32:13 +0100 Subject: [PATCH 05/14] :green_heart: [#1] comment out non-existent links in the docs --- docs/index.rst | 39 +++++++++++++++++++++------------------ 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/docs/index.rst b/docs/index.rst index a9b3572..f4fedb1 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -5,17 +5,19 @@ Welcome to django_setup_configuration's documentation! ====================================================== -|build-status| |code-quality| |black| |coverage| |docs| +|build-status| |code-quality| |black| |coverage| -|python-versions| |django-versions| |pypi-version| +.. + |docs| |python-versions| |django-versions| |pypi-version| - +Django Setup Configuration allows to make a pluggable configuration setup +used with the django management command. Features ======== -* ... -* ... +* management command, which runs the ordered list of all configuration steps +* docker-compose example to use the command as an init container .. toctree:: :maxdepth: 2 @@ -33,28 +35,29 @@ Indices and tables * :ref:`search` -.. |build-status| image:: https://github.com/maykinmedia/django_setup_configuration/workflows/Run%20CI/badge.svg +.. |build-status| image:: https://github.com/maykinmedia/django-setup-configuration/workflows/Run%20CI/badge.svg :alt: Build status - :target: https://github.com/maykinmedia/django_setup_configuration/actions?query=workflow%3A%22Run+CI%22 + :target: https://github.com/maykinmedia/django-setup-configuration/actions?query=workflow%3A%22Run+CI%22 -.. |code-quality| image:: https://github.com/maykinmedia/django_setup_configuration/workflows/Code%20quality%20checks/badge.svg +.. |code-quality| image:: https://github.com/maykinmedia/django-setup-configuration/workflows/Code%20quality%20checks/badge.svg :alt: Code quality checks - :target: https://github.com/maykinmedia/django_setup_configuration/actions?query=workflow%3A%22Code+quality+checks%22 + :target: https://github.com/maykinmedia/django-setup-configuration/actions?query=workflow%3A%22Code+quality+checks%22 .. |black| image:: https://img.shields.io/badge/code%20style-black-000000.svg :target: https://github.com/psf/black -.. |coverage| image:: https://codecov.io/gh/maykinmedia/django_setup_configuration/branch/master/graph/badge.svg - :target: https://codecov.io/gh/maykinmedia/django_setup_configuration +.. |coverage| image:: https://codecov.io/gh/maykinmedia/django-setup-configuration/branch/main/graph/badge.svg + :target: https://codecov.io/gh/maykinmedia/django-setup-configuration :alt: Coverage status -.. |docs| image:: https://readthedocs.org/projects/django_setup_configuration/badge/?version=latest - :target: https://django_setup_configuration.readthedocs.io/en/latest/?badge=latest - :alt: Documentation Status +.. + .. |docs| image:: https://readthedocs.org/projects/django_setup_configuration/badge/?version=latest + :target: https://django_setup_configuration.readthedocs.io/en/latest/?badge=latest + :alt: Documentation Status -.. |python-versions| image:: https://img.shields.io/pypi/pyversions/django_setup_configuration.svg + .. |python-versions| image:: https://img.shields.io/pypi/pyversions/django-setup-configuration.svg -.. |django-versions| image:: https://img.shields.io/pypi/djversions/django_setup_configuration.svg + .. |django-versions| image:: https://img.shields.io/pypi/djversions/django-setup-configuration.svg -.. |pypi-version| image:: https://img.shields.io/pypi/v/django_setup_configuration.svg - :target: https://pypi.org/project/django_setup_configuration/ + .. |pypi-version| image:: https://img.shields.io/pypi/v/django-setup-configuration.svg + :target: https://pypi.org/project/django-setup-configuration/ From 2cc9bae713734ead66f60ba3ed28bd1cd381c1ef Mon Sep 17 00:00:00 2001 From: Anna Shamray Date: Wed, 21 Feb 2024 16:07:22 +0100 Subject: [PATCH 06/14] :sparkles: [#1] small improvement of the package code and description --- django_setup_configuration/configuration.py | 3 --- .../management/commands/setup_configuration.py | 10 +++++++++- pyproject.toml | 14 ++++++++------ tests/test_dummy.py | 2 -- 4 files changed, 17 insertions(+), 12 deletions(-) delete mode 100644 tests/test_dummy.py diff --git a/django_setup_configuration/configuration.py b/django_setup_configuration/configuration.py index e75d422..9656b4a 100644 --- a/django_setup_configuration/configuration.py +++ b/django_setup_configuration/configuration.py @@ -1,12 +1,9 @@ -import logging from abc import ABC, abstractmethod from django.conf import settings from .exceptions import PrerequisiteFailed -logger = logging.getLogger(__name__) - class BaseConfigurationStep(ABC): verbose_name: str diff --git a/django_setup_configuration/management/commands/setup_configuration.py b/django_setup_configuration/management/commands/setup_configuration.py index 91bb48f..55269fe 100644 --- a/django_setup_configuration/management/commands/setup_configuration.py +++ b/django_setup_configuration/management/commands/setup_configuration.py @@ -2,6 +2,7 @@ from django.conf import settings from django.core.management import BaseCommand, CommandError +from django.db import transaction from django.utils.module_loading import import_string from ...configuration import BaseConfigurationStep @@ -36,16 +37,23 @@ def add_arguments(self, parser): ), ) + @transaction.atomic def handle(self, **options): overwrite: bool = options["overwrite"] - # todo transaction atomic errors = ErrorDict() steps: list[BaseConfigurationStep] = [ import_string(path)() for path in settings.SETUP_CONFIGURATION_STEPS ] enabled_steps = [step for step in steps if step.is_enabled()] + if not enabled_steps: + self.stdout.write( + "There are no enabled configuration steps. " + "Configuration can't be set up" + ) + return + self.stdout.write( f"Configuration would be set up with following steps: {enabled_steps}" ) diff --git a/pyproject.toml b/pyproject.toml index df63bf1..47f56e4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,13 +5,13 @@ build-backend = "setuptools.build_meta" [project] name = "django_setup_configuration" version = "0.1.0" -description = "TODO" +description = "Pluggable configuration setup used with the django management command" authors = [ {name = "Maykin Media", email = "support@maykinmedia.nl"} ] readme = "README.rst" license = {file = "LICENSE"} -keywords = ["TODO"] +keywords = ["Django", "Configuration"] classifiers = [ "Development Status :: 3 - Alpha", "Framework :: Django", @@ -31,15 +31,17 @@ dependencies = [ ] [project.urls] -Homepage = "https://github.com/maykinmedia/django_setup_configuration" -Documentation = "http://django_setup_configuration.readthedocs.io/en/latest/" -"Bug Tracker" = "https://github.com/maykinmedia/django_setup_configuration/issues" -"Source Code" = "https://github.com/maykinmedia/django_setup_configuration" +Homepage = "https://github.com/maykinmedia/django-setup-configuration" +Documentation = "http://django-setup-configuration.readthedocs.io/en/latest/" +"Bug Tracker" = "https://github.com/maykinmedia/django-setup-configuration/issues" +"Source Code" = "https://github.com/maykinmedia/django-setup-configuration" [project.optional-dependencies] tests = [ "pytest", "pytest-django", + "pytest-mock", + "furl", "tox", "isort", "black", diff --git a/tests/test_dummy.py b/tests/test_dummy.py deleted file mode 100644 index f4f5361..0000000 --- a/tests/test_dummy.py +++ /dev/null @@ -1,2 +0,0 @@ -def test_dummy(): - assert True From 6c047d78edd297c08606efeb7a45cb131faa0e20 Mon Sep 17 00:00:00 2001 From: Anna Shamray Date: Wed, 21 Feb 2024 16:55:35 +0100 Subject: [PATCH 07/14] :white_check_mark: [#1] a create testapp and add tests --- testapp/configuration.py | 47 ++++++++++++ testapp/settings.py | 14 ++++ tests/test_command.py | 145 ++++++++++++++++++++++++++++++++++++ tests/test_configuration.py | 38 ++++++++++ 4 files changed, 244 insertions(+) create mode 100644 testapp/configuration.py create mode 100644 tests/test_command.py create mode 100644 tests/test_configuration.py diff --git a/testapp/configuration.py b/testapp/configuration.py new file mode 100644 index 0000000..f56dee9 --- /dev/null +++ b/testapp/configuration.py @@ -0,0 +1,47 @@ +from django.conf import settings +from django.contrib.auth import authenticate +from django.contrib.auth.models import User + +from django_setup_configuration.configuration import BaseConfigurationStep +from django_setup_configuration.exceptions import SelfTestFailed + + +class UserConfigurationStep(BaseConfigurationStep): + """ + Set up an initial user + + The configuration set up a particular user only once in the regular mode. + If you want to change password or permissions use 'overwrite' mode + """ + + verbose_name = "User Configuration" + required_settings = ["USER_CONFIGURATION_USERNAME", "USER_CONFIGURATION_PASSWORD"] + enable_setting = "USER_CONFIGURATION_ENABLED" + + def is_configured(self) -> bool: + return User.objects.filter( + username=settings.USER_CONFIGURATION_USERNAME + ).exists() + + def configure(self) -> None: + user_qs = User.objects.filter(username=settings.USER_CONFIGURATION_USERNAME) + if user_qs.exists(): + user = user_qs.get() + if not user.check_password(settings.USER_CONFIGURATION_PASSWORD): + user.set_password(settings.USER_CONFIGURATION_PASSWORD) + user.save() + + else: + User.objects.create_user( + username=settings.USER_CONFIGURATION_USERNAME, + password=settings.USER_CONFIGURATION_PASSWORD, + is_superuser=True, + ) + + def test_configuration(self) -> None: + user = authenticate( + username=settings.USER_CONFIGURATION_USERNAME, + password=settings.USER_CONFIGURATION_PASSWORD, + ) + if not user: + raise SelfTestFailed("No user with provided credentials is found") diff --git a/testapp/settings.py b/testapp/settings.py index 765cd25..fef34af 100644 --- a/testapp/settings.py +++ b/testapp/settings.py @@ -1,3 +1,4 @@ +import os from pathlib import Path BASE_DIR = Path(__file__).resolve(strict=True).parent @@ -50,3 +51,16 @@ ] ROOT_URLCONF = "testapp.urls" + +# +# django-setup-configuration settings +# +SETUP_CONFIGURATION_STEPS = ["testapp.configuration.UserConfigurationStep"] + + +# +# testapp settings +# +USER_CONFIGURATION_ENABLED = os.getenv("USER_CONFIGURATION_ENABLED", True) +USER_CONFIGURATION_USERNAME = os.getenv("USER_CONFIGURATION_USERNAME", "demo") +USER_CONFIGURATION_PASSWORD = os.getenv("USER_CONFIGURATION_PASSWORD", "secret") diff --git a/tests/test_command.py b/tests/test_command.py new file mode 100644 index 0000000..5b2a001 --- /dev/null +++ b/tests/test_command.py @@ -0,0 +1,145 @@ +from io import StringIO + +from django.conf import settings +from django.contrib.auth.models import User +from django.core.management import CommandError, call_command + +import pytest + +from testapp.configuration import UserConfigurationStep + +pytestmark = pytest.mark.django_db + + +def test_command_success(): + """ + test happy flow + """ + assert User.objects.count() == 0 + + stdout = StringIO() + call_command("setup_configuration", stdout=stdout) + + output = stdout.getvalue().splitlines() + step = UserConfigurationStep() + expected_output = [ + f"Configuration would be set up with following steps: [{step}]", + f"Configuring {step}...", + f"{step} is successfully configured", + "Instance configuration completed.", + ] + assert output == expected_output + + assert User.objects.count() == 1 + user = User.objects.get() + assert user.username == settings.USER_CONFIGURATION_USERNAME + assert user.check_password(settings.USER_CONFIGURATION_PASSWORD) is True + + +def test_command_called_twice(settings): + """ + test that the second run doesn't change configuration + """ + # 1st call + settings.USER_CONFIGURATION_PASSWORD = "secret1" + call_command("setup_configuration") + user = User.objects.get(username=settings.USER_CONFIGURATION_USERNAME) + assert user.check_password("secret1") is True + + # 2nd call + settings.USER_CONFIGURATION_PASSWORD = "secret2" + stdout = StringIO() + call_command("setup_configuration", stdout=stdout) + + output = stdout.getvalue().splitlines() + step = UserConfigurationStep() + expected_output = [ + f"Configuration would be set up with following steps: [{step}]", + f"Step {step} is skipped, because the configuration already exists.", + "Instance configuration completed.", + ] + assert output == expected_output + + user = User.objects.get(username=settings.USER_CONFIGURATION_USERNAME) + assert user.check_password("secret1") is True + + +def test_command_called_twice_with_overwrite(): + """ + test that the second run change configuration in overwrite mode + """ + # 1st call + settings.USER_CONFIGURATION_PASSWORD = "secret1" + call_command("setup_configuration") + user = User.objects.get(username=settings.USER_CONFIGURATION_USERNAME) + assert user.check_password("secret1") is True + + # 2nd call + settings.USER_CONFIGURATION_PASSWORD = "secret2" + stdout = StringIO() + call_command("setup_configuration", overwrite=True, stdout=stdout) + + output = stdout.getvalue().splitlines() + step = UserConfigurationStep() + expected_output = [ + f"Configuration would be set up with following steps: [{step}]", + f"Configuring {step}...", + f"{step} is successfully configured", + "Instance configuration completed.", + ] + assert output == expected_output + + user = User.objects.get(username=settings.USER_CONFIGURATION_USERNAME) + assert user.check_password("secret2") is True + + +def test_command_no_config_steps(settings): + """ + test that the command quits in the beginning with appropriate stdout + """ + settings.SETUP_CONFIGURATION_STEPS = [] + + stdout = StringIO() + call_command("setup_configuration", stdout=stdout) + + output = stdout.getvalue().splitlines() + expected_output = [ + "There are no enabled configuration steps. Configuration can't be set up", + ] + assert output == expected_output + assert User.objects.count() == 0 + + +def test_command_disabled(settings): + """ + test that the command doesn't run the disabled configuration step + """ + settings.USER_CONFIGURATION_ENABLED = False + + stdout = StringIO() + call_command("setup_configuration", stdout=stdout) + + output = stdout.getvalue().splitlines() + expected_output = [ + "There are no enabled configuration steps. Configuration can't be set up", + ] + assert output == expected_output + assert User.objects.count() == 0 + + +def test_command_failed_selftest(mocker): + """ + test that if configuration.test_configuration fails with SelfTestFailed + CommandError is raised and no db change is done + """ + mocker.patch("testapp.configuration.authenticate", return_value=None) + + with pytest.raises(CommandError) as exc: + call_command("setup_configuration") + + exc_description = ( + f"Configuration test failed with errors: " + f"{UserConfigurationStep()}: No user with provided credentials is found" + ) + assert exc.value.args[0] == exc_description + assert User.objects.count() == 0 diff --git a/tests/test_configuration.py b/tests/test_configuration.py new file mode 100644 index 0000000..11cf7a8 --- /dev/null +++ b/tests/test_configuration.py @@ -0,0 +1,38 @@ +import pytest + +from django_setup_configuration.exceptions import PrerequisiteFailed +from testapp.configuration import UserConfigurationStep + + +def test_configuration_enabled(): + """ + test that configuration is enabled when the related setting is set to True + """ + assert UserConfigurationStep().is_enabled() is True + + +def test_configuration_disabled(settings): + """ + test that configuration is enabled when the related setting is set to False + """ + settings.USER_CONFIGURATION_ENABLED = False + + assert UserConfigurationStep().is_enabled() is False + + +def test_prerequisites_valid(): + """ + test that no error is raised when necessary settings are provided + """ + UserConfigurationStep().validate_requirements() + + +def test_prerequisites_invalid(settings): + """ + test that PrerequisiteFailed is raised when necessary settings are missing + """ + settings.USER_CONFIGURATION_USERNAME = "" + settings.USER_CONFIGURATION_PASSWORD = "" + + with pytest.raises(PrerequisiteFailed): + UserConfigurationStep().validate_requirements() From bda38bb38116603f3496f3fc3a12b02a9795c522 Mon Sep 17 00:00:00 2001 From: Anna Shamray Date: Wed, 21 Feb 2024 16:59:29 +0100 Subject: [PATCH 08/14] :memo: [#1] update docs --- docs/index.rst | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/index.rst b/docs/index.rst index f4fedb1..a88b709 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -17,7 +17,6 @@ Features ======== * management command, which runs the ordered list of all configuration steps -* docker-compose example to use the command as an init container .. toctree:: :maxdepth: 2 From a47235e76633d65533a34f9058a5cfb27873b4ec Mon Sep 17 00:00:00 2001 From: Anna Shamray Date: Sat, 24 Feb 2024 18:14:02 +0100 Subject: [PATCH 09/14] :sparkles: [#1] add option to disable selftests in the command --- .../commands/setup_configuration.py | 33 +++++++++++++------ tests/test_command.py | 13 ++++++++ 2 files changed, 36 insertions(+), 10 deletions(-) diff --git a/django_setup_configuration/management/commands/setup_configuration.py b/django_setup_configuration/management/commands/setup_configuration.py index 55269fe..e09abb0 100644 --- a/django_setup_configuration/management/commands/setup_configuration.py +++ b/django_setup_configuration/management/commands/setup_configuration.py @@ -36,10 +36,20 @@ def add_arguments(self, parser): "of the env variables have been changed." ), ) + parser.add_argument( + "--no-selftest", + action="store_true", + dest="skip_selftest", + help=( + "Skip checking if configuration is successful. Use it if you " + "run this command in the init container before the web app is started" + ), + ) @transaction.atomic def handle(self, **options): overwrite: bool = options["overwrite"] + skip_selftest: bool = options["skip_selftest"] errors = ErrorDict() steps: list[BaseConfigurationStep] = [ @@ -89,16 +99,19 @@ def handle(self, **options): configured_steps.append(step) # 3. Test configuration - for step in configured_steps: - # todo global env to turn off self tests? - try: - step.test_configuration() - except SelfTestFailed as exc: - errors[step] = exc + if skip_selftest: + self.stdout.write("Selftest is skipped.") - if errors: - raise CommandError( - f"Configuration test failed with errors: {errors.as_text()}" - ) + else: + for step in configured_steps: + try: + step.test_configuration() + except SelfTestFailed as exc: + errors[step] = exc + + if errors: + raise CommandError( + f"Configuration test failed with errors: {errors.as_text()}" + ) self.stdout.write(self.style.SUCCESS("Instance configuration completed.")) diff --git a/tests/test_command.py b/tests/test_command.py index 5b2a001..8bfd3b8 100644 --- a/tests/test_command.py +++ b/tests/test_command.py @@ -143,3 +143,16 @@ def test_command_failed_selftest(mocker): ) assert exc.value.args[0] == exc_description assert User.objects.count() == 0 + + +def test_command_skip_selftest(mocker): + """ + test that command skips selftest + """ + stdout = StringIO() + mocker.patch("testapp.configuration.authenticate", return_value=None) + + call_command("setup_configuration", no_selftest=True, stdout=stdout) + + output = stdout.getvalue() + assert "Selftest is skipped." in output From 16d3330a9e42da7fe084f58058143e02170ef36b Mon Sep 17 00:00:00 2001 From: Anna Shamray <47745906+annashamray@users.noreply.github.com> Date: Tue, 27 Feb 2024 14:52:47 +0100 Subject: [PATCH 10/14] Apply suggestions from code review Co-authored-by: Sergei Maertens --- .../management/commands/setup_configuration.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/django_setup_configuration/management/commands/setup_configuration.py b/django_setup_configuration/management/commands/setup_configuration.py index e09abb0..0c5a7ce 100644 --- a/django_setup_configuration/management/commands/setup_configuration.py +++ b/django_setup_configuration/management/commands/setup_configuration.py @@ -65,7 +65,7 @@ def handle(self, **options): return self.stdout.write( - f"Configuration would be set up with following steps: {enabled_steps}" + f"Configuration will be set up with following steps: {enabled_steps}" ) # 1. Check prerequisites of all steps From 67dabde5108522b113b0194d88601651be735022 Mon Sep 17 00:00:00 2001 From: Anna Shamray Date: Tue, 27 Feb 2024 15:04:35 +0100 Subject: [PATCH 11/14] :ok_hand: [#1] proccess PR feedback --- django_setup_configuration/exceptions.py | 6 +++--- .../management/commands/setup_configuration.py | 6 ++---- tests/test_command.py | 6 +++--- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/django_setup_configuration/exceptions.py b/django_setup_configuration/exceptions.py index 7cd5c1a..d8aac32 100644 --- a/django_setup_configuration/exceptions.py +++ b/django_setup_configuration/exceptions.py @@ -6,17 +6,17 @@ class ConfigurationException(Exception): class PrerequisiteFailed(ConfigurationException): """ - Raise an error then configuration step can't be started + Raises an error when the configuration step can't be started """ class ConfigurationRunFailed(ConfigurationException): """ - Raise an error then configuration process was faulty + Raises an error when the configuration process was faulty """ class SelfTestFailed(ConfigurationException): """ - Raise an error for failed configuration self-tests. + Raises an error for failed configuration self-tests. """ diff --git a/django_setup_configuration/management/commands/setup_configuration.py b/django_setup_configuration/management/commands/setup_configuration.py index 0c5a7ce..2d1bb26 100644 --- a/django_setup_configuration/management/commands/setup_configuration.py +++ b/django_setup_configuration/management/commands/setup_configuration.py @@ -1,5 +1,3 @@ -from collections import OrderedDict - from django.conf import settings from django.core.management import BaseCommand, CommandError from django.db import transaction @@ -9,7 +7,7 @@ from ...exceptions import ConfigurationRunFailed, PrerequisiteFailed, SelfTestFailed -class ErrorDict(OrderedDict): +class ErrorDict(dict): """ small helper to display errors """ @@ -21,7 +19,7 @@ def as_text(self) -> str: class Command(BaseCommand): help = ( - "Bootstrap the initial Open Zaak configuration. " + "Bootstrap the initial configuration of the application. " "This command is run only in non-interactive mode with settings " "configured mainly via environment variables." ) diff --git a/tests/test_command.py b/tests/test_command.py index 8bfd3b8..b305729 100644 --- a/tests/test_command.py +++ b/tests/test_command.py @@ -23,7 +23,7 @@ def test_command_success(): output = stdout.getvalue().splitlines() step = UserConfigurationStep() expected_output = [ - f"Configuration would be set up with following steps: [{step}]", + f"Configuration will be set up with following steps: [{step}]", f"Configuring {step}...", f"{step} is successfully configured", "Instance configuration completed.", @@ -54,7 +54,7 @@ def test_command_called_twice(settings): output = stdout.getvalue().splitlines() step = UserConfigurationStep() expected_output = [ - f"Configuration would be set up with following steps: [{step}]", + f"Configuration will be set up with following steps: [{step}]", f"Step {step} is skipped, because the configuration already exists.", "Instance configuration completed.", ] @@ -82,7 +82,7 @@ def test_command_called_twice_with_overwrite(): output = stdout.getvalue().splitlines() step = UserConfigurationStep() expected_output = [ - f"Configuration would be set up with following steps: [{step}]", + f"Configuration will be set up with following steps: [{step}]", f"Configuring {step}...", f"{step} is successfully configured", "Instance configuration completed.", From 674ea6af3ac7aacc704e72931f351786a4b338a1 Mon Sep 17 00:00:00 2001 From: Anna Shamray Date: Tue, 27 Feb 2024 17:49:18 +0100 Subject: [PATCH 12/14] :ok_hand: [#1] explicitly list invalid values for required settings --- django_setup_configuration/configuration.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/django_setup_configuration/configuration.py b/django_setup_configuration/configuration.py index 9656b4a..dc8a4cf 100644 --- a/django_setup_configuration/configuration.py +++ b/django_setup_configuration/configuration.py @@ -21,7 +21,9 @@ def validate_requirements(self) -> None: if prerequisites are missing """ missing = [ - var for var in self.required_settings if not getattr(settings, var, None) + var + for var in self.required_settings + if getattr(settings, var, None) in [None, ""] ] if missing: raise PrerequisiteFailed( From 723a014f4a6e08ece2648fa9899ce529f3fe0310 Mon Sep 17 00:00:00 2001 From: Anna Shamray Date: Tue, 5 Mar 2024 17:31:31 +0100 Subject: [PATCH 13/14] :wrench: [#1] support Django 4.2 --- .github/workflows/ci.yml | 2 +- pyproject.toml | 1 + tox.ini | 4 +++- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3a08799..90edac8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: strategy: matrix: python: ['3.10', '3.11', '3.12'] - django: ['3.2'] + django: ['3.2', '4.2'] name: Run the test suite (Python $, Django $) diff --git a/pyproject.toml b/pyproject.toml index 47f56e4..1cc2eee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,7 @@ classifiers = [ "Development Status :: 3 - Alpha", "Framework :: Django", "Framework :: Django :: 3.2", + "Framework :: Django :: 4.2", "Intended Audience :: Developers", "Operating System :: Unix", "Operating System :: MacOS", diff --git a/tox.ini b/tox.ini index 7b4b71a..ea25821 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] envlist = - py{310,311,312}-django{32} + py{310,311,312}-django{32,42} isort black flake8 @@ -16,6 +16,7 @@ python = [gh-actions:env] DJANGO = 3.2: django32 + 4.2: django42 [testenv] setenv = @@ -26,6 +27,7 @@ extras = coverage deps = django32: Django~=3.2.0 + django42: Django~=4.2.0 commands = py.test tests \ --cov --cov-report xml:reports/coverage-{envname}.xml \ From c17c48a292f3ad893467e587198a386b53fd1557 Mon Sep 17 00:00:00 2001 From: Anna Shamray Date: Tue, 5 Mar 2024 17:34:14 +0100 Subject: [PATCH 14/14] :wrench: [#1] fix gh action name --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 90edac8..f4f5e31 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,7 @@ jobs: python: ['3.10', '3.11', '3.12'] django: ['3.2', '4.2'] - name: Run the test suite (Python $, Django $) + name: Run the test suite (Python ${{ matrix.python }}, Django ${{ matrix.django }}) steps: - uses: actions/checkout@v4