diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b0d7b7a..f4f5e31 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,15 +16,15 @@ jobs: strategy: matrix: python: ['3.10', '3.11', '3.12'] - django: ['4.2'] + 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 - 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/django_setup_configuration/configuration.py b/django_setup_configuration/configuration.py new file mode 100644 index 0000000..dc8a4cf --- /dev/null +++ b/django_setup_configuration/configuration.py @@ -0,0 +1,69 @@ +from abc import ABC, abstractmethod + +from django.conf import settings + +from .exceptions import PrerequisiteFailed + + +class BaseConfigurationStep(ABC): + verbose_name: str + required_settings: list[str] = [] + enable_setting: str = "" + + def __repr__(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 getattr(settings, var, None) in [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..d8aac32 --- /dev/null +++ b/django_setup_configuration/exceptions.py @@ -0,0 +1,22 @@ +class ConfigurationException(Exception): + """ + Base exception for configuration steps + """ + + +class PrerequisiteFailed(ConfigurationException): + """ + Raises an error when the configuration step can't be started + """ + + +class ConfigurationRunFailed(ConfigurationException): + """ + Raises an error when the configuration process was faulty + """ + + +class SelfTestFailed(ConfigurationException): + """ + Raises 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..2d1bb26 --- /dev/null +++ b/django_setup_configuration/management/commands/setup_configuration.py @@ -0,0 +1,115 @@ +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 +from ...exceptions import ConfigurationRunFailed, PrerequisiteFailed, SelfTestFailed + + +class ErrorDict(dict): + """ + 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 configuration of the application. " + "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." + ), + ) + 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] = [ + 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 will 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 + if skip_selftest: + self.stdout.write("Selftest is skipped.") + + 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/docs/index.rst b/docs/index.rst index 347ecec..a88b709 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -3,19 +3,20 @@ contain the root `toctree` directive. 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 .. toctree:: :maxdepth: 2 @@ -33,28 +34,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/ diff --git a/pyproject.toml b/pyproject.toml index 22a369d..1cc2eee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,16 +5,17 @@ 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", + "Framework :: Django :: 3.2", "Framework :: Django :: 4.2", "Intended Audience :: Developers", "Operating System :: Unix", @@ -27,19 +28,21 @@ classifiers = [ ] requires-python = ">=3.10" dependencies = [ - "django>=4.2" + "django>=3.2" ] [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/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..b305729 --- /dev/null +++ b/tests/test_command.py @@ -0,0 +1,158 @@ +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 will 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 will 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 will 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 + + +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 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() 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 diff --git a/tox.ini b/tox.ini index a3488f8..ea25821 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,42} isort black flake8 @@ -15,6 +15,7 @@ python = [gh-actions:env] DJANGO = + 3.2: django32 4.2: django42 [testenv] @@ -25,6 +26,7 @@ extras = tests coverage deps = + django32: Django~=3.2.0 django42: Django~=4.2.0 commands = py.test tests \