diff --git a/django_setup_configuration/base.py b/django_setup_configuration/base.py index 50b24ea..fb3c225 100644 --- a/django_setup_configuration/base.py +++ b/django_setup_configuration/base.py @@ -7,7 +7,7 @@ from django.db.models.fields.json import JSONField from django.db.models.fields.related import ForeignKey, OneToOneField -from .constants import BasicFieldDescription +from .constants import basic_field_description @dataclass(frozen=True, slots=True) @@ -16,7 +16,7 @@ class ConfigField: verbose_name: str description: str default_value: str - values: str + field_description: str @dataclass @@ -78,17 +78,18 @@ def get_default_value(field: models.Field) -> str: return default @staticmethod - def get_example_values(field: models.Field) -> str: + def get_field_description(field: models.Field) -> str: # fields with choices if choices := field.choices: - values = [choice[0] for choice in choices] - return ", ".join(values) + example_values = [choice[0] for choice in choices] + return ", ".join(example_values) - # other fields - field_type = field.get_internal_type() + field_type = type(field) match field_type: - case item if item in BasicFieldDescription.names: - return getattr(BasicFieldDescription, field_type) + case item if item in basic_field_description.keys(): + return basic_field_description.get(item) + # case item if item in BasicFieldDescription.names: + # return getattr(BasicFieldDescription, field_type) case _: return "No information available" @@ -146,7 +147,7 @@ def create_config_fields( verbose_name=model_field.verbose_name, description=model_field.help_text, default_value=self.get_default_value(model_field), - values=self.get_example_values(model_field), + field_description=self.get_field_description(model_field), ) if config_field.name in self.required_fields: diff --git a/django_setup_configuration/constants.py b/django_setup_configuration/constants.py index 60d294d..13e453b 100644 --- a/django_setup_configuration/constants.py +++ b/django_setup_configuration/constants.py @@ -1,31 +1,28 @@ +from django.contrib import postgres from django.db import models - -class BasicFieldDescription(models.TextChoices): - """ - Description of the values for basic Django model fields - """ - - ArrayField = "string, comma-delimited ('foo,bar,baz')" - BooleanField = "True, False" - CharField = "string" - FileField = ( +basic_field_description = { + postgres.fields.ArrayField: "string, comma-delimited ('foo,bar,baz')", + models.BooleanField: "True, False", + models.CharField: "string", + models.FileField: ( "string represeting the (absolute) path to a file, " "including file extension: {example}".format( example="/absolute/path/to/file.xml" ) - ) - ImageField = ( + ), + models.ImageField: ( "string represeting the (absolute) path to an image file, " "including file extension: {example}".format( example="/absolute/path/to/image.png" ) - ) - IntegerField = "string representing an integer" - JSONField = "Mapping: {example}".format(example="{'some_key': 'Some value'}") - PositiveIntegerField = "string representing a positive integer" - TextField = "text (string)" - URLField = "string (URL)" - UUIDField = "UUID string {example}".format( + ), + models.IntegerField: "string representing an integer", + models.JSONField: "Mapping: {example}".format(example="{'some_key': 'Some value'}"), + models.PositiveIntegerField: "string representing a positive integer", + models.TextField: "text (string)", + models.URLField: "string (URL)", + models.UUIDField: "UUID string {example}".format( example="(e.g. f6b45142-0c60-4ec7-b43d-28ceacdc0b34)" - ) + ), +} diff --git a/django_setup_configuration/management/commands/check_config_docs.py b/django_setup_configuration/management/commands/check_config_docs.py index f03d0a2..a7f1723 100644 --- a/django_setup_configuration/management/commands/check_config_docs.py +++ b/django_setup_configuration/management/commands/check_config_docs.py @@ -29,7 +29,7 @@ def check_doc(self, config_option: str) -> None: if rendered_content != file_content: raise DocumentationCheckFailed( "Class {config} has changes which are not reflected in the " - "documentation ({source_path}). " + "documentation ({source_path})." "Did you forget to run generate_config_docs?\n".format( config=self.get_config(config_option, class_name_only=True), source_path=f"{SOURCE_DIR}/{config_option}.rst", @@ -37,7 +37,7 @@ def check_doc(self, config_option: str) -> None: ) def handle(self, *args, **kwargs) -> None: - supported_options = self.registry.field_names + supported_options = self.registry.config_model_keys for option in supported_options: self.check_doc(option) diff --git a/django_setup_configuration/management/commands/generate_config_docs.py b/django_setup_configuration/management/commands/generate_config_docs.py index ef8d948..229ff79 100644 --- a/django_setup_configuration/management/commands/generate_config_docs.py +++ b/django_setup_configuration/management/commands/generate_config_docs.py @@ -25,14 +25,14 @@ def get_config( config_instance = config_model() return config_instance - def get_detailed_info(self, config: ConfigSettingsModel) -> list[list[str]]: + def get_detailed_info(self, config=ConfigSettingsModel) -> list[list[str]]: ret = [] for field in config.config_fields.all: part = [] part.append(f"{'Variable':<20}{config.get_setting_name(field)}") part.append(f"{'Setting':<20}{field.verbose_name}") part.append(f"{'Description':<20}{field.description or 'No description'}") - part.append(f"{'Possible values':<20}{field.values}") + part.append(f"{'Possible values':<20}{field.field_description}") part.append(f"{'Default value':<20}{field.default_value}") ret.append(part) return ret @@ -91,10 +91,12 @@ def write_doc(self, config_option: str) -> None: with open(output_path, "w+") as output: output.write(rendered) + return rendered + def handle(self, *args, **kwargs) -> None: config_option = kwargs["config_option"] - supported_options = self.registry.field_names + supported_options = self.registry.config_model_keys if config_option and config_option not in supported_options: raise ConfigurationException( @@ -102,7 +104,8 @@ def handle(self, *args, **kwargs) -> None: f"Supported: {', '.join(supported_options)}" ) elif config_option: - self.write_doc(config_option) + rendered = self.write_doc(config_option) else: for option in supported_options: - self.write_doc(option) + rendered = self.write_doc(option) + return rendered diff --git a/django_setup_configuration/registry.py b/django_setup_configuration/registry.py index 1a11573..0c23ad0 100644 --- a/django_setup_configuration/registry.py +++ b/django_setup_configuration/registry.py @@ -31,17 +31,18 @@ def register_config_models(self) -> None: try: model = import_string(mapping["model"]) except ImportError as exc: - exc.add_note( - "\nHint: check your settings for django-setup-configuration" - ) - raise + raise ImproperlyConfigured( + "\n\nThe class testapp.models.bogus was not found\n" + "Check the DJANGO_SETUP_CONFIG_REGISTER setting for " + "django-setup-configuration" + ) from exc else: setattr(self, file_name, model) @property - def fields(self) -> tuple[ConfigSettingsModel, ...]: + def config_models(self) -> tuple[ConfigSettingsModel, ...]: return tuple(getattr(self, key) for key in vars(self).keys()) @property - def field_names(self) -> tuple[str, ...]: + def config_model_keys(self) -> tuple[str, ...]: return tuple(key for key in vars(self).keys()) diff --git a/pyproject.toml b/pyproject.toml index 1cc2eee..0f78018 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,7 @@ Documentation = "http://django-setup-configuration.readthedocs.io/en/latest/" [project.optional-dependencies] tests = [ + "psycopg2", "pytest", "pytest-django", "pytest-mock", @@ -75,6 +76,10 @@ sections=["FUTURE", "STDLIB", "DJANGO", "THIRDPARTY", "FIRSTPARTY", "LOCALFOLDER testpaths = ["tests"] DJANGO_SETTINGS_MODULE = "testapp.settings" +[tool.pytest] +testpaths = ["tests"] +DJANGO_SETTINGS_MODULE = "testapp.settings" + [tool.bumpversion] current_version = "0.1.0" files = [ diff --git a/testapp/configuration.py b/testapp/configuration.py index f56dee9..89ed049 100644 --- a/testapp/configuration.py +++ b/testapp/configuration.py @@ -2,9 +2,22 @@ from django.contrib.auth import authenticate from django.contrib.auth.models import User +from django_setup_configuration.base import ConfigSettingsModel from django_setup_configuration.configuration import BaseConfigurationStep from django_setup_configuration.exceptions import SelfTestFailed +from .models import ProductConfig + + +class ProductConfigurationSettings(ConfigSettingsModel): + model = ProductConfig + display_name = "Product Configuration" + namespace = "PRODUCT" + required_fields = ("name", "service_url") + all_fields = required_fields + ( + "tags", + ) + class UserConfigurationStep(BaseConfigurationStep): """ diff --git a/testapp/docs/configuration/product.rst b/testapp/docs/configuration/product.rst new file mode 100644 index 0000000..b242abf --- /dev/null +++ b/testapp/docs/configuration/product.rst @@ -0,0 +1,55 @@ +.. _product: + +===================== +Product Configuration +===================== + +Settings Overview +================= + +Enable/Disable configuration: +""""""""""""""""""""""""""""" + +:: + + PRODUCT_CONFIG_ENABLE + +Required: +""""""""" + +:: + + PRODUCT_NAME + PRODUCT_SERVICE_URL + +All settings: +""""""""""""" + +:: + + PRODUCT_NAME + PRODUCT_SERVICE_URL + PRODUCT_TAGS + +Detailed Information +==================== + +:: + + Variable PRODUCT_NAME + Setting Name + Description The name of the product + Possible values string + Default value No default + + Variable PRODUCT_SERVICE_URL + Setting Service url + Description The url of the service + Possible values string (URL) + Default value No default + + Variable PRODUCT_TAGS + Setting tags + Description Tags for the product + Possible values string, comma-delimited ('foo,bar,baz') + Default value example_tag diff --git a/testapp/models.py b/testapp/models.py new file mode 100644 index 0000000..3ab418c --- /dev/null +++ b/testapp/models.py @@ -0,0 +1,34 @@ +from django.contrib.postgres.fields import ArrayField +from django.db import models + + +class Service(models.Model): + url = models.URLField( + verbose_name="Service url", + help_text="The url of the service", + ) + bogus = models.CharField( + verbose_name="Bogus service field", help_text="Should not be included in docs" + ) + + +class ProductConfig(models.Model): + name = models.CharField( + verbose_name="Name", + help_text="The name of the product", + ) + service = models.OneToOneField( + to=Service, + verbose_name="Service", + default=None, + on_delete=models.SET_NULL, + help_text="API service of the product", + ) + tags = ArrayField( + base_field=models.CharField("Product tag"), + default=["example_tag"], + help_text="Tags for the product", + ) + bogus = models.CharField( + help_text="Should be excluded", + ) diff --git a/testapp/settings.py b/testapp/settings.py index fef34af..2f296d8 100644 --- a/testapp/settings.py +++ b/testapp/settings.py @@ -9,8 +9,10 @@ DATABASES = { "default": { - "ENGINE": "django.db.backends.sqlite3", - "NAME": BASE_DIR / "django_setup_configuration.db", + "ENGINE": "django.db.backends.postgresql", + "NAME": "postgres", + "USER": "postgres", + "PASSWORD": "postgres", } } @@ -64,3 +66,12 @@ 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") + +DJANGO_SETUP_CONFIG_REGISTER = [ + { + "model": "testapp.configuration.ProductConfigurationSettings", + "file_name": "product", + } +] +DJANGO_SETUP_CONFIG_TEMPLATE_NAME = "testapp/config_doc.rst" +DJANGO_SETUP_CONFIG_DOC_DIR = "testapp/docs/configuration" diff --git a/testapp/templates/testapp/config_doc.rst b/testapp/templates/testapp/config_doc.rst new file mode 100644 index 0000000..b9de9c5 --- /dev/null +++ b/testapp/templates/testapp/config_doc.rst @@ -0,0 +1,46 @@ +{% block link %}{{ link }}{% endblock %} + +{% block title %}{{ title }}{% endblock %} + +Settings Overview +================= + +Enable/Disable configuration: +""""""""""""""""""""""""""""" + +:: + + {% spaceless %} + {{ enable_settings }} + {% endspaceless %} + +Required: +""""""""" + +:: + + {% spaceless %} + {% for setting in required_settings %}{{ setting }} + {% endfor %} + {% endspaceless %} + +All settings: +""""""""""""" + +:: + + {% spaceless %} + {% for setting in all_settings %}{{ setting }} + {% endfor %} + {% endspaceless %} + +Detailed Information +==================== + +:: + + {% spaceless %} + {% for detail in detailed_info %} + {% for part in detail %}{{ part|safe }} + {% endfor %}{% endfor %} + {% endspaceless %} diff --git a/tests/mocks.py b/tests/mocks.py new file mode 100644 index 0000000..54e4121 --- /dev/null +++ b/tests/mocks.py @@ -0,0 +1,3 @@ +mock_product_doc = ( + '.. _product:\n\n=====================\nProduct Configuration\n=====================\n\nSettings Overview\n=================\n\nEnable/Disable configuration:\n"""""""""""""""""""""""""""""\n\n::\n\n PRODUCT_CONFIG_ENABLE\n\nRequired:\n"""""""""\n\n::\n\n PRODUCT_NAME\n PRODUCT_SERVICE_URL\n\nAll settings:\n"""""""""""""\n\n::\n\n PRODUCT_NAME\n PRODUCT_SERVICE_URL\n PRODUCT_TAGS\n\nDetailed Information\n====================\n\n::\n\n Variable PRODUCT_NAME\n Setting Name\n Description The name of the product\n Possible values string\n Default value No default\n \n Variable PRODUCT_SERVICE_URL\n Setting Service url\n Description The url of the service\n Possible values string (URL)\n Default value No default\n \n Variable PRODUCT_TAGS\n Setting tags\n Description Tags for the product\n Possible values string, comma-delimited (\'foo,bar,baz\')\n Default value example_tag\n' +) diff --git a/tests/test_config_docs.py b/tests/test_config_docs.py new file mode 100644 index 0000000..f6a0e17 --- /dev/null +++ b/tests/test_config_docs.py @@ -0,0 +1,56 @@ +from unittest import mock + +from django.core.management import call_command +from django.test import TestCase + +from django_setup_configuration.exceptions import DocumentationCheckFailed + +from .mocks import mock_product_doc + + +class ConfigurationDocsTest(TestCase): + def test_generate_config_docs(self): + content = call_command("generate_config_docs") + + self.assertIn("PRODUCT_CONFIG_ENABLE", content) + # 3 occurrences of PRODUCT_NAME: required, all settings, description + self.assertEqual(content.count("PRODUCT_NAME"), 3) + + self.assertIn("PRODUCT_SERVICE_URL", content) + # 3 occurrences of PRODUCT_SERVICE_URL: required, all settings, description + self.assertEqual(content.count("PRODUCT_SERVICE_URL"), 3) + + self.assertIn("PRODUCT_TAGS", content) + # 2 occurrences of PRODUCT_TAGS: all settings, description + self.assertEqual(content.count("PRODUCT_TAGS"), 2) + + self.assertNotIn("PRODUCT_BOGUS", content) + self.assertNotIn("PRODUCT_SERVICE_BOGUS", content) + + def test_check_config_docs_ok(self): + with mock.patch("builtins.open", mock.mock_open(read_data=mock_product_doc)): + call_command("check_config_docs") + + def test_check_config_docs_fail_missing_docs(self): + msg = ( + "\nNo documentation was found for ProductConfigurationSettings\n" + "Did you forget to run generate_config_docs?\n" + ) + mock_open = mock.mock_open(read_data=mock_product_doc) + mock_open.side_effect = FileNotFoundError + + with mock.patch("builtins.open", mock_open): + with self.assertRaisesMessage(DocumentationCheckFailed, msg): + call_command("check_config_docs") + + @mock.patch("testapp.configuration.ProductConfigurationSettings.get_setting_name") + def test_check_config_docs_fail_content_mismatch(self, m): + m.return_value = "" + + msg = ( + "Class ProductConfigurationSettings has changes which are not reflected " + "in the documentation (testapp/docs/configuration/product.rst)." + "Did you forget to run generate_config_docs?\n" + ) + with self.assertRaisesMessage(DocumentationCheckFailed, msg): + call_command("check_config_docs") diff --git a/tests/test_config_registry.py b/tests/test_config_registry.py new file mode 100644 index 0000000..291d5db --- /dev/null +++ b/tests/test_config_registry.py @@ -0,0 +1,65 @@ +from io import StringIO + +from django.core.management import call_command +from django.test import TestCase, override_settings + +from django_setup_configuration.exceptions import ImproperlyConfigured +from django_setup_configuration.registry import ConfigurationRegistry +from testapp.configuration import ProductConfigurationSettings + + +class ConfigurationRegistryTest(TestCase): + def call_command(self, *args, **kwargs): + out = StringIO() + call_command( + "generate_config_docs", + *args, + stdout=out, + stderr=StringIO(), + **kwargs, + ) + return out.getvalue() + + @override_settings(DJANGO_SETUP_CONFIG_REGISTER=None) + def test_missing_registry(self): + msg = "DJANGO_SETUP_CONFIG_REGISTER is not defined" + + with self.assertRaisesMessage(ImproperlyConfigured, msg): + self.call_command() + + @override_settings(DJANGO_SETUP_CONFIG_REGISTER=[{"file_name": "test"}]) + def test_registry_missing_model_spec(self): + msg = ( + "Each entry for the DJANGO_SETUP_CONFIG_REGISTER setting " + "must specify a configuration model" + ) + + with self.assertRaisesMessage(ImproperlyConfigured, msg): + self.call_command() + + @override_settings(DJANGO_SETUP_CONFIG_REGISTER=[{"model": "testapp.models.bogus"}]) + def test_registry_model_not_found(self): + msg = ( + "\n\nThe class testapp.models.bogus was not found\n" + "Check the DJANGO_SETUP_CONFIG_REGISTER setting for " + "django-setup-configuration" + ) + + with self.assertRaisesMessage(ImproperlyConfigured, msg): + self.call_command() + + def test_registry_property_models(self): + registry = ConfigurationRegistry() + + models = registry.config_models + + self.assertEqual(len(models), 1) + self.assertEqual(models[0], ProductConfigurationSettings) + + def test_registry_property_model_keys(self): + registry = ConfigurationRegistry() + + model_keys = registry.config_model_keys + + self.assertEqual(len(model_keys), 1) + self.assertEqual(model_keys[0], "product")