diff --git a/CHANGES.md b/CHANGES.md index 4084810..1e64d3b 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,8 @@ # Changelog +**11.4.0** (2024-09-24) + * Added system check to enforce naming conventions for DateFields and DateTimeFields + **11.3.0** (2024-09-17) * Added date util functions `get_current_year` and `check_date_is_weekend` * Improved date utils docs diff --git a/ambient_toolbox/__init__.py b/ambient_toolbox/__init__.py index 4a82a28..0697442 100644 --- a/ambient_toolbox/__init__.py +++ b/ambient_toolbox/__init__.py @@ -1,3 +1,3 @@ """Python toolbox of Ambient Digital containing an abundance of useful tools and gadgets.""" -__version__ = "11.3.0" +__version__ = "11.4.0" diff --git a/ambient_toolbox/system_checks/__init__.py b/ambient_toolbox/system_checks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ambient_toolbox/system_checks/model_field_name_conventions.py b/ambient_toolbox/system_checks/model_field_name_conventions.py new file mode 100644 index 0000000..7d73655 --- /dev/null +++ b/ambient_toolbox/system_checks/model_field_name_conventions.py @@ -0,0 +1,56 @@ +from django.apps import apps +from django.conf import settings +from django.core import checks +from django.db.models import DateField, DateTimeField + + +def check_model_time_based_fields(*args, **kwargs): + """ + Checks all model time fields ('DateField', 'DateTimeField') for a "correct" ending in their name. + Inspired by: https://lukeplant.me.uk/blog/posts/enforcing-conventions-in-django-projects-with-introspection/ + """ + + project_apps = [ + app.split(".")[-1] for app in settings.INSTALLED_APPS if app.startswith(settings.ROOT_URLCONF.split(".")[0]) + ] + issue_list = [] + + # Allowlists + allowed_datetime_field_endings = getattr(settings, "ALLOWED_MODEL_DATETIME_FIELD_ENDINGS", ["_at"]) + allowed_date_field_endings = getattr(settings, "ALLOWED_MODEL_DATE_FIELD_ENDINGS", ["_date"]) + + str_allowed_datetime_endings = ", ".join(allowed_datetime_field_endings) + str_allowed_date_endings = ", ".join(allowed_date_field_endings) + + # Iterate all registered models... + for model in apps.get_models(): + # Check if the model is from your project... + if model._meta.app_label in project_apps: + # Iterate over all fields... + for field in model._meta.get_fields(): + # Case: DateTimeField, noqa: ERA001 + if isinstance(field, DateTimeField): + # Check field name ending against allowlist + if not field.name.lower().endswith(tuple(allowed_datetime_field_endings)): + issue_list.append( + checks.Warning( + f"DateTimeField '{model.__name__}.{field.name}' doesn't end with: " + f"{str_allowed_datetime_endings}.", + obj=field, + id="ambient_toolbox.W001", + ) + ) + # Case: Date field, noqa: ERA001 + elif isinstance(field, DateField): + # Check field name ending against allowlist + if not field.name.lower().endswith(tuple(allowed_date_field_endings)): + issue_list.append( + checks.Warning( + f"DateField '{model.__name__}.{field.name}' doesn't end with: " + f"{str_allowed_date_endings}.", + obj=field, + id="ambient_toolbox.W002", + ) + ) + + return issue_list diff --git a/docs/features/system_checks.md b/docs/features/system_checks.md new file mode 100644 index 0000000..55367ce --- /dev/null +++ b/docs/features/system_checks.md @@ -0,0 +1,37 @@ +# System checks + +## Model field naming conventions + +Inspired +by [Luke Plants article](https://lukeplant.me.uk/blog/posts/enforcing-conventions-in-django-projects-with-introspection/), +this package implements a system check to ensure that all custom DateField and DateTimeField are named in a uniform +manner. + +By default, it requires for DateFields to end on `_date` and DateTimeFields on `_at`. + +It's straightforward to register this system check in your project. + +````python +# apps/common/apps.py +from ambient_toolbox.system_checks.model_field_name_conventions import check_model_time_based_fields + +from django.apps import AppConfig +from django.core.checks import register + + +class CommonConfig(AppConfig): + name = "apps.common" + verbose_name = "Common" + + def ready(self): + register(check_model_time_based_fields) +```` + +You can configure which field name endings are allowed by setting these variables in your global Django settings file. + +````python +# apps/config/settings.py + +ALLOWED_MODEL_DATETIME_FIELD_ENDINGS = ["_at"] +ALLOWED_MODEL_DATE_FIELD_ENDINGS = ["_date"] +```` diff --git a/docs/index.rst b/docs/index.rst index 8473b10..a5d6b10 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -31,6 +31,7 @@ The package is published at pypi under the following link: `https://pypi.org/pro features/sentry.md features/services.md features/static_role_permissions.md + features/system_checks.md features/tests.md features/translations.md features/utils.rst diff --git a/testapp/migrations/0001_initial.py b/testapp/migrations/0001_initial.py index 8d26036..a6d01f3 100644 --- a/testapp/migrations/0001_initial.py +++ b/testapp/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.0.7 on 2024-07-15 14:19 +# Generated by Django 5.0.7 on 2024-09-24 03:59 import ambient_toolbox.mixins.bleacher import ambient_toolbox.mixins.models @@ -52,6 +52,24 @@ class Migration(migrations.Migration): ], bases=(ambient_toolbox.mixins.bleacher.BleacherMixin, models.Model), ), + migrations.CreateModel( + name="ModelNameTimeBasedFieldTest", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("wrongly_named_date_field", models.DateField()), + ("wrongly_named_datetime_field", models.DateTimeField()), + ("timestamp_date", models.DateField()), + ("timestamped_at", models.DateTimeField()), + ], + ), migrations.CreateModel( name="ModelWithCleanMixin", fields=[ diff --git a/testapp/models.py b/testapp/models.py index 6f9f932..f875a82 100644 --- a/testapp/models.py +++ b/testapp/models.py @@ -121,3 +121,13 @@ def __str__(self): @receiver(pre_save, sender=ModelWithSaveWithoutSignalsMixin) def increase_value_on_pre_save(sender, instance, **kwargs): instance.value += 1 + + +class ModelNameTimeBasedFieldTest(models.Model): + wrongly_named_date_field = models.DateField() + wrongly_named_datetime_field = models.DateTimeField() + timestamp_date = models.DateField() + timestamped_at = models.DateTimeField() + + def __str__(self): + return self.id diff --git a/tests/system_checks/__init__.py b/tests/system_checks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/system_checks/test_model_field_name_conventions.py b/tests/system_checks/test_model_field_name_conventions.py new file mode 100644 index 0000000..8a38ed5 --- /dev/null +++ b/tests/system_checks/test_model_field_name_conventions.py @@ -0,0 +1,46 @@ +from django.core import checks +from django.test import SimpleTestCase, override_settings + +from ambient_toolbox.system_checks.model_field_name_conventions import check_model_time_based_fields +from testapp.models import ModelNameTimeBasedFieldTest + + +class CheckModelTimeBasedFieldsTest(SimpleTestCase): + def test_check_regular(self): + # Create expected warnings + datetime_warning = checks.Warning( + "DateTimeField 'ModelNameTimeBasedFieldTest.wrongly_named_datetime_field' doesn't end with: _at.", + obj=ModelNameTimeBasedFieldTest.wrongly_named_datetime_field.field, + id="ambient_toolbox.W001", + ) + date_warning = checks.Warning( + "DateField 'ModelNameTimeBasedFieldTest.wrongly_named_date_field' doesn't end with: _date.", + obj=ModelNameTimeBasedFieldTest.wrongly_named_date_field.field, + id="ambient_toolbox.W002", + ) + + # Call system check + error_list = check_model_time_based_fields() + + # Assert warngins + self.assertEqual(len(error_list), 2) + self.assertIn(datetime_warning, error_list) + self.assertIn(date_warning, error_list) + + @override_settings(ALLOWED_MODEL_DATETIME_FIELD_ENDINGS=["wrongly_named_datetime_field", "_at"]) + def test_check_allowlist_works_datetime_field(self): + # Call system check + error_list = check_model_time_based_fields() + + # Assert warngins + self.assertEqual(len(error_list), 1) + self.assertEqual(error_list[0].id, "ambient_toolbox.W002") + + @override_settings(ALLOWED_MODEL_DATE_FIELD_ENDINGS=["wrongly_named_date_field", "_date"]) + def test_check_allowlist_works_date_field(self): + # Call system check + error_list = check_model_time_based_fields() + + # Assert warngins + self.assertEqual(len(error_list), 1) + self.assertEqual(error_list[0].id, "ambient_toolbox.W001")