From de81826568c3ae785dd0fb97b84c04c53c620639 Mon Sep 17 00:00:00 2001 From: Anna Shamray Date: Fri, 9 Feb 2024 17:22:03 +0100 Subject: [PATCH] :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]