From 32694b1324ab5ce04f4166a071af67d41abb159f Mon Sep 17 00:00:00 2001 From: yeongkwang Date: Mon, 23 May 2022 17:02:22 +0900 Subject: [PATCH] Add register model using Django settings (#368) Co-authored-by: Hasan Ramezani --- CHANGELOG.md | 4 ++ auditlog/apps.py | 5 ++ auditlog/conf.py | 17 +++++ auditlog/registry.py | 95 ++++++++++++++++++++++++- auditlog_tests/tests.py | 154 +++++++++++++++++++++++++++++++++++++++- docs/source/usage.rst | 54 ++++++++++++++ 6 files changed, 326 insertions(+), 3 deletions(-) create mode 100644 auditlog/conf.py diff --git a/CHANGELOG.md b/CHANGELOG.md index cd6db51..0880ba5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changes +#### Improvements + +- feat: Add register model from settings ([#368](https://github.com/jazzband/django-auditlog/pull/368)) + #### Fixes - Fix inconsistent changes with JSONField ([#355](https://github.com/jazzband/django-auditlog/pull/355)) diff --git a/auditlog/apps.py b/auditlog/apps.py index 097558f..0e9266e 100644 --- a/auditlog/apps.py +++ b/auditlog/apps.py @@ -5,3 +5,8 @@ class AuditlogConfig(AppConfig): name = "auditlog" verbose_name = "Audit log" default_auto_field = "django.db.models.AutoField" + + def ready(self): + from auditlog.registry import auditlog + + auditlog.register_from_settings() diff --git a/auditlog/conf.py b/auditlog/conf.py new file mode 100644 index 0000000..fdb685c --- /dev/null +++ b/auditlog/conf.py @@ -0,0 +1,17 @@ +from django.conf import settings + +# Register all models when set to True +settings.AUDITLOG_INCLUDE_ALL_MODELS = getattr( + settings, "AUDITLOG_INCLUDE_ALL_MODELS", False +) + +# Exclude models in registration process +# It will be considered when `AUDITLOG_INCLUDE_ALL_MODELS` is True +settings.AUDITLOG_EXCLUDE_TRACKING_MODELS = getattr( + settings, "AUDITLOG_EXCLUDE_TRACKING_MODELS", () +) + +# Register models and define their logging behaviour +settings.AUDITLOG_INCLUDE_TRACKING_MODELS = getattr( + settings, "AUDITLOG_INCLUDE_TRACKING_MODELS", () +) diff --git a/auditlog/registry.py b/auditlog/registry.py index 2b1bb3d..8093213 100644 --- a/auditlog/registry.py +++ b/auditlog/registry.py @@ -1,9 +1,13 @@ -from typing import Callable, Dict, List, Optional, Tuple +import copy +from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple, Union +from django.apps import apps from django.db.models import Model from django.db.models.base import ModelBase from django.db.models.signals import ModelSignal, post_delete, post_save, pre_save +from auditlog.conf import settings + DispatchUID = Tuple[int, str, int] @@ -12,6 +16,8 @@ class AuditlogModelRegistry: A registry that keeps track of the models that use Auditlog to track changes. """ + DEFAULT_EXCLUDE_MODELS = ("auditlog.LogEntry", "admin.LogEntry") + def __init__( self, create: bool = True, @@ -147,5 +153,92 @@ class AuditlogModelRegistry: """ return self.__hash__(), model.__qualname__, signal.__hash__() + def _get_model_classes(self, app_model: str) -> List[ModelBase]: + try: + try: + app_label, model_name = app_model.split(".") + return [apps.get_model(app_label, model_name)] + except ValueError: + return apps.get_app_config(app_model).get_models() + except LookupError: + return [] + + def _get_exclude_models( + self, exclude_tracking_models: Iterable[str] + ) -> List[ModelBase]: + exclude_models = [ + model + for app_model in exclude_tracking_models + self.DEFAULT_EXCLUDE_MODELS + for model in self._get_model_classes(app_model) + ] + return exclude_models + + def _register_models(self, models: Iterable[Union[str, Dict[str, Any]]]) -> None: + models = copy.deepcopy(models) + for model in models: + if isinstance(model, str): + for model_class in self._get_model_classes(model): + self.unregister(model_class) + self.register(model_class) + elif isinstance(model, dict): + model["model"] = self._get_model_classes(model["model"])[0] + self.unregister(model["model"]) + self.register(**model) + + def register_from_settings(self): + """ + Register models from settings variables + """ + if not isinstance(settings.AUDITLOG_INCLUDE_ALL_MODELS, bool): + raise TypeError("Setting 'AUDITLOG_INCLUDE_ALL_MODELS' must be a boolean") + + if not isinstance(settings.AUDITLOG_EXCLUDE_TRACKING_MODELS, (list, tuple)): + raise TypeError( + "Setting 'AUDITLOG_EXCLUDE_TRACKING_MODELS' must be a list or tuple" + ) + + if ( + not settings.AUDITLOG_INCLUDE_ALL_MODELS + and settings.AUDITLOG_EXCLUDE_TRACKING_MODELS + ): + raise ValueError( + "In order to use setting 'AUDITLOG_EXCLUDE_TRACKING_MODELS', " + "setting 'AUDITLOG_INCLUDE_ALL_MODELS' must set to 'True'" + ) + + if not isinstance(settings.AUDITLOG_INCLUDE_TRACKING_MODELS, (list, tuple)): + raise TypeError( + "Setting 'AUDITLOG_INCLUDE_TRACKING_MODELS' must be a list or tuple" + ) + + for item in settings.AUDITLOG_INCLUDE_TRACKING_MODELS: + if not isinstance(item, (str, dict)): + raise TypeError( + "Setting 'AUDITLOG_INCLUDE_TRACKING_MODELS' items must be str or dict" + ) + + if isinstance(item, dict): + if "model" not in item: + raise ValueError( + "Setting 'AUDITLOG_INCLUDE_TRACKING_MODELS' dict items must contain 'model' key" + ) + if "." not in item["model"]: + raise ValueError( + "Setting 'AUDITLOG_INCLUDE_TRACKING_MODELS' model must be in the format ." + ) + + if settings.AUDITLOG_INCLUDE_ALL_MODELS: + exclude_models = self._get_exclude_models( + settings.AUDITLOG_EXCLUDE_TRACKING_MODELS + ) + models = apps.get_models() + + for model in models: + if model in exclude_models: + continue + self.register(model) + + self._register_models(settings.AUDITLOG_INCLUDE_TRACKING_MODELS) + auditlog = AuditlogModelRegistry() diff --git a/auditlog_tests/tests.py b/auditlog_tests/tests.py index 6fd52da..5b18b5a 100644 --- a/auditlog_tests/tests.py +++ b/auditlog_tests/tests.py @@ -3,19 +3,20 @@ import json import django from dateutil.tz import gettz +from django.apps import apps from django.conf import settings from django.contrib import auth from django.contrib.auth.models import AnonymousUser, User from django.core.exceptions import ValidationError from django.db.models.signals import pre_save from django.http import HttpResponse -from django.test import RequestFactory, TestCase +from django.test import RequestFactory, TestCase, override_settings from django.utils import dateformat, formats, timezone from auditlog.diff import model_instance_diff from auditlog.middleware import AuditlogMiddleware from auditlog.models import LogEntry -from auditlog.registry import auditlog +from auditlog.registry import AuditlogModelRegistry, auditlog from auditlog_tests.models import ( AdditionalDataIncludedModel, AltPrimaryKeyModel, @@ -792,6 +793,155 @@ class UnregisterTest(TestCase): self.assertEqual(LogEntry.objects.count(), 0, msg="There are no log entries") +class RegisterModelSettingsTest(TestCase): + def setUp(self): + self.test_auditlog = AuditlogModelRegistry() + + def tearDown(self): + for model in self.test_auditlog.get_models(): + self.test_auditlog.unregister(model) + + def test_get_model_classes(self): + self.assertEqual( + len(list(self.test_auditlog._get_model_classes("auditlog"))), + len(list(apps.get_app_config("auditlog").get_models())), + ) + self.assertEqual([], self.test_auditlog._get_model_classes("fake_model")) + + def test_get_exclude_models(self): + # By default it returns DEFAULT_EXCLUDE_MODELS + self.assertEqual(len(self.test_auditlog._get_exclude_models(())), 2) + + # Exclude just one model + self.assertTrue( + SimpleExcludeModel + in self.test_auditlog._get_exclude_models( + ("auditlog_tests.SimpleExcludeModel",) + ) + ) + + # Exclude all model of an app + self.assertTrue( + SimpleExcludeModel + in self.test_auditlog._get_exclude_models(("auditlog_tests",)) + ) + + def test_register_models_no_models(self): + self.test_auditlog._register_models(()) + + self.assertEqual(self.test_auditlog._registry, {}) + + def test_register_models_register_single_model(self): + self.test_auditlog._register_models(("auditlog_tests.SimpleExcludeModel",)) + + self.assertTrue(self.test_auditlog.contains(SimpleExcludeModel)) + self.assertEqual(len(self.test_auditlog._registry), 1) + + def test_register_models_register_app(self): + self.test_auditlog._register_models(("auditlog_tests",)) + + self.assertTrue(self.test_auditlog.contains(SimpleExcludeModel)) + self.assertTrue(self.test_auditlog.contains(ChoicesFieldModel)) + self.assertEqual(len(self.test_auditlog.get_models()), 17) + + def test_register_models_register_model_with_attrs(self): + self.test_auditlog._register_models( + ( + { + "model": "auditlog_tests.SimpleExcludeModel", + "include_fields": ["label"], + "exclude_fields": [ + "text", + ], + }, + ) + ) + + self.assertTrue(self.test_auditlog.contains(SimpleExcludeModel)) + fields = self.test_auditlog.get_model_fields(SimpleExcludeModel) + self.assertEqual(fields["include_fields"], ["label"]) + self.assertEqual(fields["exclude_fields"], ["text"]) + + def test_register_from_settings_invalid_settings(self): + with override_settings(AUDITLOG_INCLUDE_ALL_MODELS="str"): + with self.assertRaisesMessage( + TypeError, "Setting 'AUDITLOG_INCLUDE_ALL_MODELS' must be a boolean" + ): + self.test_auditlog.register_from_settings() + + with override_settings(AUDITLOG_EXCLUDE_TRACKING_MODELS="str"): + with self.assertRaisesMessage( + TypeError, + "Setting 'AUDITLOG_EXCLUDE_TRACKING_MODELS' must be a list or tuple", + ): + self.test_auditlog.register_from_settings() + + with override_settings(AUDITLOG_EXCLUDE_TRACKING_MODELS=("app1.model1",)): + with self.assertRaisesMessage( + ValueError, + "In order to use setting 'AUDITLOG_EXCLUDE_TRACKING_MODELS', " + "setting 'AUDITLOG_INCLUDE_ALL_MODELS' must set to 'True'", + ): + self.test_auditlog.register_from_settings() + + with override_settings(AUDITLOG_INCLUDE_TRACKING_MODELS="str"): + with self.assertRaisesMessage( + TypeError, + "Setting 'AUDITLOG_INCLUDE_TRACKING_MODELS' must be a list or tuple", + ): + self.test_auditlog.register_from_settings() + + with override_settings(AUDITLOG_INCLUDE_TRACKING_MODELS=(1, 2)): + with self.assertRaisesMessage( + TypeError, + "Setting 'AUDITLOG_INCLUDE_TRACKING_MODELS' items must be str or dict", + ): + self.test_auditlog.register_from_settings() + + with override_settings(AUDITLOG_INCLUDE_TRACKING_MODELS=({"test": "test"},)): + with self.assertRaisesMessage( + ValueError, + "Setting 'AUDITLOG_INCLUDE_TRACKING_MODELS' dict items must contain 'model' key", + ): + self.test_auditlog.register_from_settings() + + with override_settings(AUDITLOG_INCLUDE_TRACKING_MODELS=({"model": "test"},)): + with self.assertRaisesMessage( + ValueError, + "Setting 'AUDITLOG_INCLUDE_TRACKING_MODELS' model must be in the format .", + ): + self.test_auditlog.register_from_settings() + + @override_settings( + AUDITLOG_INCLUDE_ALL_MODELS=True, + AUDITLOG_EXCLUDE_TRACKING_MODELS=("auditlog_tests.SimpleExcludeModel",), + ) + def test_register_from_settings_register_all_models_with_exclude_models(self): + self.test_auditlog.register_from_settings() + + self.assertFalse(self.test_auditlog.contains(SimpleExcludeModel)) + self.assertTrue(self.test_auditlog.contains(ChoicesFieldModel)) + + @override_settings( + AUDITLOG_INCLUDE_TRACKING_MODELS=( + { + "model": "auditlog_tests.SimpleExcludeModel", + "include_fields": ["label"], + "exclude_fields": [ + "text", + ], + }, + ) + ) + def test_register_from_settings_register_models(self): + self.test_auditlog.register_from_settings() + + self.assertTrue(self.test_auditlog.contains(SimpleExcludeModel)) + fields = self.test_auditlog.get_model_fields(SimpleExcludeModel) + self.assertEqual(fields["include_fields"], ["label"]) + self.assertEqual(fields["exclude_fields"], ["text"]) + + class ChoicesFieldModelTest(TestCase): def setUp(self): self.obj = ChoicesFieldModel.objects.create( diff --git a/docs/source/usage.rst b/docs/source/usage.rst index f5c3f99..f650c7c 100644 --- a/docs/source/usage.rst +++ b/docs/source/usage.rst @@ -91,6 +91,60 @@ For example, to mask the field ``address``, use:: Masking fields + +Settings +-------- + +**AUDITLOG_INCLUDE_ALL_MODELS** + +You can use this setting to register all your models: + +.. code-block:: python + + AUDITLOG_INCLUDE_ALL_MODELS=True + +.. versionadded:: 2.1.0 + +**AUDITLOG_EXCLUDE_TRACKING_MODELS** + +You can use this setting to exclude models in registration process. +It will be considered when ``AUDITLOG_INCLUDE_ALL_MODELS`` is `True`. + +.. code-block:: python + + AUDITLOG_EXCLUDE_TRACKING_MODELS = ( + "", + "." + ) + +.. versionadded:: 2.1.0 + +**AUDITLOG_INCLUDE_TRACKING_MODELS** + +You can use this setting to configure your models registration and other behaviours. +It must be a list or tuple. Each item in this setting can be a: + +* ``str``: To register a model. +* ``dict``: To register a model and define its logging behaviour. e.g. include_fields, exclude_fields. + +.. code-block:: python + + AUDITLOG_INCLUDE_TRACKING_MODELS = ( + ".", + { + "model": ".", + "include_fields": ["field1", "field2"], + "exclude_fields": ["field3", "field4"], + "mapping_fields": { + "field1": "FIELD", + }, + "mask_fields": ["field5", "field6"], + }, + ".", + ) + +.. versionadded:: 2.1.0 + Actors ------