Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add new settings attr AUDITLOG_REGISTRY to allow custom auditlogs #623

Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
## 3.0.0-beta.4 (2024-01-02)

#### Improvements
- feat: Add `AUDITLOG_REGISTRY` settings attribute to have multiple auditlogs with custom configurations ([#623](https://github.com/jazzband/django-auditlog/pull/623))
- feat: Excluding ip address when `AUDITLOG_DISABLE_REMOTE_ADDR` is set to True ([#620](https://github.com/jazzband/django-auditlog/pull/620))
- feat: If any receiver returns False, no logging will be made. This can be useful if logging should be conditionally enabled / disabled ([#590](https://github.com/jazzband/django-auditlog/pull/590))
- Django: Confirm Django 5.0 support ([#598](https://github.com/jazzband/django-auditlog/pull/598))
Expand Down
5 changes: 5 additions & 0 deletions auditlog/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,8 @@
settings.AUDITLOG_DISABLE_REMOTE_ADDR = getattr(
settings, "AUDITLOG_DISABLE_REMOTE_ADDR", False
)

# List of paths to auditlogs
settings.AUDITLOG_REGISTRY = getattr(
settings, "AUDITLOG_REGISTRY", ["auditlog.registry.auditlog"]
)
8 changes: 4 additions & 4 deletions auditlog/diff.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ def model_instance_diff(
field values as value.
:rtype: dict
"""
from auditlog.registry import auditlog
from auditlog.registry import AuditlogRegistry

if not (old is None or isinstance(old, Model)):
raise TypeError("The supplied old instance is not a valid model instance.")
Expand All @@ -135,13 +135,13 @@ def model_instance_diff(

if old is not None and new is not None:
fields = set(old._meta.fields + new._meta.fields)
model_fields = auditlog.get_model_fields(new._meta.model)
model_fields = AuditlogRegistry.get_model_fields(new._meta.model)
elif old is not None:
fields = set(get_fields_in_model(old))
model_fields = auditlog.get_model_fields(old._meta.model)
model_fields = AuditlogRegistry.get_model_fields(old._meta.model)
elif new is not None:
fields = set(get_fields_in_model(new))
model_fields = auditlog.get_model_fields(new._meta.model)
model_fields = AuditlogRegistry.get_model_fields(new._meta.model)
else:
fields = set()
model_fields = None
Expand Down
4 changes: 2 additions & 2 deletions auditlog/mixins.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from django.utils.translation import gettext_lazy as _

from auditlog.models import LogEntry
from auditlog.registry import auditlog
from auditlog.registry import AuditlogRegistry
from auditlog.signals import accessed

MAX = 75
Expand Down Expand Up @@ -142,7 +142,7 @@ def field_verbose_name(self, obj, field_name: str):
if model is None:
return field_name
try:
model_fields = auditlog.get_model_fields(model._meta.model)
model_fields = AuditlogRegistry.get_model_fields(model._meta.model)
mapping_field_name = model_fields["mapping_fields"].get(field_name)
if mapping_field_name:
return mapping_field_name
Expand Down
10 changes: 7 additions & 3 deletions auditlog/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,9 @@ def _get_pk_value(self, instance):
return pk

def _get_serialized_data_or_none(self, instance):
from auditlog.registry import auditlog
from auditlog.registry import AuditlogRegistry

auditlog = AuditlogRegistry.get_auditlog(model=instance.__class__)

opts = auditlog.get_serialize_options(instance.__class__)
if not opts["serialize_data"]:
Expand Down Expand Up @@ -436,12 +438,14 @@ def changes_display_dict(self):
"""
:return: The changes recorded in this log entry intended for display to users as a dictionary object.
"""
from auditlog.registry import auditlog
from auditlog.registry import AuditlogRegistry

# Get the model and model_fields, but gracefully handle the case where the model no longer exists
model = self.content_type.model_class()
auditlog = AuditlogRegistry.get_auditlog(model=model)

model_fields = None
if auditlog.contains(model._meta.model):
if auditlog:
model_fields = auditlog.get_model_fields(model._meta.model)

changes_display_dict = {}
Expand Down
55 changes: 55 additions & 0 deletions auditlog/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
)

from django.apps import apps
from django.core.exceptions import ImproperlyConfigured
from django.db.models import ManyToManyField, Model
from django.db.models.base import ModelBase
from django.db.models.signals import (
Expand All @@ -22,6 +23,7 @@
post_save,
pre_save,
)
from django.utils.module_loading import import_string

from auditlog.conf import settings
from auditlog.signals import accessed
Expand Down Expand Up @@ -363,3 +365,56 @@


auditlog = AuditlogModelRegistry()


def get_default_auditlogs():
return get_auditlogs(settings.AUDITLOG_REGISTRY)


def get_auditlogs(auditlog_config):
auditlogs = []

for auditlog in auditlog_config:
try:
audit_logger = import_string(auditlog)
except ImportError:
msg = (

Check warning on line 381 in auditlog/registry.py

View check run for this annotation

Codecov / codecov/patch

auditlog/registry.py#L380-L381

Added lines #L380 - L381 were not covered by tests
"The module in NAME could not be imported: %s. Check your "
"AUDITLOG_REGISTRY setting."
)
raise ImproperlyConfigured(msg % auditlog["NAME"])

Check warning on line 385 in auditlog/registry.py

View check run for this annotation

Codecov / codecov/patch

auditlog/registry.py#L385

Added line #L385 was not covered by tests
else:
auditlogs.append(audit_logger)
return auditlogs


class AuditlogRegistry:
"""
A registry that keeps track of AuditlogModelRegistry instances.
"""

_registry = {}

@classmethod
def get_auditlog(cls, model: ModelBase):
"""
Retrieve the auditlog object associated with a given model.
"""
auditlogs = get_default_auditlogs()
for auditlog in auditlogs:
if auditlog.contains(model):
return auditlog

@classmethod
def get_model_fields(cls, model: ModelBase):
"""
Wrapper of AuditlogModelRegistry.get_model_fields().
"""
auditlog = cls.get_auditlog(model)
if auditlog:
model_fields = auditlog.get_model_fields(model)
return model_fields
return {}


auditlog_registry = AuditlogRegistry()
7 changes: 4 additions & 3 deletions auditlog_tests/admin.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from django.contrib import admin

from auditlog.registry import auditlog
from auditlog.registry import get_default_auditlogs

for model in auditlog.get_models():
admin.site.register(model)
for auditlog in get_default_auditlogs():
for model in auditlog.get_models():
admin.site.register(model)
9 changes: 9 additions & 0 deletions auditlog_tests/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,12 @@

class AuditlogTestConfig(AppConfig):
name = "auditlog_tests"

def ready(self) -> None:
from auditlog_tests.test_registry import (
create_only_auditlog,
update_only_auditlog,
)

create_only_auditlog.register_from_settings()
update_only_auditlog.register_from_settings()
16 changes: 16 additions & 0 deletions auditlog_tests/test_registry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from auditlog.registry import AuditlogModelRegistry

create_only_auditlog = AuditlogModelRegistry(
create=True,
update=False,
delete=False,
access=False,
m2m=False,
)
update_only_auditlog = AuditlogModelRegistry(
create=False,
update=True,
delete=False,
access=False,
m2m=False,
)
73 changes: 73 additions & 0 deletions auditlog_tests/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
SimpleNonManagedModel,
UUIDPrimaryKeyModel,
)
from auditlog_tests.test_registry import create_only_auditlog, update_only_auditlog


class SimpleModelTest(TestCase):
Expand Down Expand Up @@ -2643,3 +2644,75 @@ def test_get_changes_for_missing_model(self):
history = self.obj.history.latest()
self.assertEqual(history.changes_dict["text"][1], self.obj.text)
self.assertEqual(history.changes_display_dict["text"][1], self.obj.text)


class CustomAuditlogTest(TestCase):
def setUp(self):
super().setUp()
if auditlog.contains(SimpleModel):
auditlog.unregister(SimpleModel)

def tearDown(self):
for model in create_only_auditlog.get_models():
create_only_auditlog.unregister(model)
for model in update_only_auditlog.get_models():
update_only_auditlog.unregister(model)
auditlog.register(SimpleModel)

def make_object(self):
return SimpleModel.objects.create(text="I am not difficult.")

def update_object(self, obj):
obj.text = "Changed"
obj.save()
return obj

def test_create_only_auditlog(self):
with override_settings(
AUDITLOG_REGISTRY=[
"auditlog_tests.test_registry.create_only_auditlog",
]
):
create_only_auditlog.register(SimpleModel, include_fields=["text"])

# Get the object to work with
obj = self.make_object()

# Check for log entries
self.assertEqual(obj.history.count(), 1, msg="There is one log entry")

history = obj.history.get()
self.check_create_log_entry(obj, history)

updated_obj = self.update_object(obj)
self.check_no_update_entry(updated_obj)

def test_update_only_auditlog(self):
with override_settings(
AUDITLOG_REGISTRY=[
"auditlog_tests.test_registry.update_only_auditlog",
]
):
update_only_auditlog.register(SimpleModel, include_fields=["text"])

# Get the object to work with
obj = self.make_object()
self.assertEqual(obj.history.all().count(), 0)
updated_obj = self.update_object(obj)
self.assertEqual(updated_obj.history.all().count(), 1)
history = updated_obj.history.get()
self.assertEqual(
history.action,
LogEntry.Action.UPDATE,
)

def check_create_log_entry(self, obj, history):
self.assertEqual(
history.action, LogEntry.Action.CREATE, msg="Action is 'CREATE'"
)
self.assertEqual(history.object_repr, str(obj), msg="Representation is equal")

def check_no_update_entry(self, obj):
self.assertEqual(
obj.history.all().filter(action=LogEntry.Action.UPDATE).count(), 0
)
11 changes: 11 additions & 0 deletions docs/source/usage.rst
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,17 @@ If the value is `None`, the default getter will be used.

.. versionadded:: 3.0.0

**AUDITLOG_REGISTRY**
A list or tuple of AuditlogModelRegistry instances.
This settings allow to configure multiple auditlogs with conditionally disabled logging per action
or to have custom signal receivers.

If not specified, this setting defaults to:
.. code-block:: python
AUDITLOG_REGISTRY = ["auditlog.registry.auditlog"]

.. versionadded:: 3.0.0

Actors
------

Expand Down
Loading