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

Adding Custom Pre- and Post- Log Hooks #483

Merged
merged 4 commits into from
Dec 27, 2022
Merged
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
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@

#### Improvements

- feat: Added support for Correlation ID
- feat: Added support for Correlation ID. ([#481](https://github.com/jazzband/django-auditlog/pull/481))
- feat: Added pre-log and post-log signals. ([#483](https://github.com/jazzband/django-auditlog/pull/483))

#### Fixes

Expand Down
5 changes: 4 additions & 1 deletion auditlog/diff.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from datetime import timezone
from typing import Optional

from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist
Expand Down Expand Up @@ -98,7 +99,9 @@ def mask_str(value: str) -> str:
return "*" * mask_limit + value[mask_limit:]


def model_instance_diff(old, new, fields_to_check=None):
def model_instance_diff(
old: Optional[Model], new: Optional[Model], fields_to_check=None
):
"""
Calculates the differences between two model instances. One of the instances may be ``None``
(i.e., a newly created model or deleted model). This will cause all fields with a value to have
Expand Down
91 changes: 61 additions & 30 deletions auditlog/receivers.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from auditlog.context import threadlocal
from auditlog.diff import model_instance_diff
from auditlog.models import LogEntry
from auditlog.signals import post_log, pre_log


def check_disable(signal_handler):
Expand Down Expand Up @@ -33,12 +34,12 @@ def log_create(sender, instance, created, **kwargs):
Direct use is discouraged, connect your model through :py:func:`auditlog.registry.register` instead.
"""
if created:
changes = model_instance_diff(None, instance)

LogEntry.objects.log_create(
instance,
_create_log_entry(
action=LogEntry.Action.CREATE,
changes=json.dumps(changes),
instance=instance,
sender=sender,
diff_old=None,
diff_new=instance,
)


Expand All @@ -50,22 +51,16 @@ def log_update(sender, instance, **kwargs):
Direct use is discouraged, connect your model through :py:func:`auditlog.registry.register` instead.
"""
if instance.pk is not None:
try:
old = sender.objects.get(pk=instance.pk)
except sender.DoesNotExist:
pass
else:
new = instance
update_fields = kwargs.get("update_fields", None)
changes = model_instance_diff(old, new, fields_to_check=update_fields)

# Log an entry only if there are changes
if changes:
LogEntry.objects.log_create(
instance,
action=LogEntry.Action.UPDATE,
changes=json.dumps(changes),
)
update_fields = kwargs.get("update_fields", None)
old = sender.objects.filter(pk=instance.pk).first()
_create_log_entry(
action=LogEntry.Action.UPDATE,
instance=instance,
sender=sender,
diff_old=old,
diff_new=instance,
fields_to_check=update_fields,
)


@check_disable
Expand All @@ -76,12 +71,12 @@ def log_delete(sender, instance, **kwargs):
Direct use is discouraged, connect your model through :py:func:`auditlog.registry.register` instead.
"""
if instance.pk is not None:
changes = model_instance_diff(instance, None)

LogEntry.objects.log_create(
instance,
_create_log_entry(
action=LogEntry.Action.DELETE,
changes=json.dumps(changes),
instance=instance,
sender=sender,
diff_old=instance,
diff_new=None,
)


Expand All @@ -92,12 +87,48 @@ def log_access(sender, instance, **kwargs):
Direct use is discouraged, connect your model through :py:func:`auditlog.registry.register` instead.
"""
if instance.pk is not None:

LogEntry.objects.log_create(
instance,
_create_log_entry(
action=LogEntry.Action.ACCESS,
changes="null",
instance=instance,
sender=sender,
diff_old=None,
diff_new=None,
force_log=True,
)


def _create_log_entry(
action, instance, sender, diff_old, diff_new, fields_to_check=None, force_log=False
):
pre_log_results = pre_log.send(
sender,
instance=instance,
action=action,
)
error = None
try:
changes = model_instance_diff(
diff_old, diff_new, fields_to_check=fields_to_check
)

if force_log or changes:
LogEntry.objects.log_create(
instance,
action=action,
changes=json.dumps(changes),
)
except BaseException as e:
error = e
finally:
post_log.send(
sender,
instance=instance,
action=action,
error=error,
pre_log_results=pre_log_results,
)
if error:
raise error


def make_log_m2m_changes(field_name):
Expand Down
50 changes: 50 additions & 0 deletions auditlog/signals.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,53 @@
import django.dispatch

accessed = django.dispatch.Signal()


pre_log = django.dispatch.Signal()
"""
Whenever an audit log entry is written, this signal
is sent before writing the log.
Keyword arguments sent with this signal:

:param class sender:
The model class that's being audited.

:param Any instance:
The actual instance that's being audited.

:param Action action:
The action on the model resulting in an
audit log entry. Type: :class:`auditlog.models.LogEntry.Action`

The receivers' return values are sent to any :func:`post_log`
signal receivers.
"""

post_log = django.dispatch.Signal()
"""
Whenever an audit log entry is written, this signal
is sent after writing the log.
Keyword arguments sent with this signal:

:param class sender:
The model class that's being audited.

:param Any instance:
The actual instance that's being audited.

:param Action action:
The action on the model resulting in an
audit log entry. Type: :class:`auditlog.models.LogEntry.Action`

:param Optional[Exception] error:
The error, if one occurred while saving the audit log entry. ``None``,
otherwise

:param List[Tuple[method,Any]] pre_log_results:
List of tuple pairs ``[(pre_log_receiver, pre_log_response)]``, where
``pre_log_receiver`` is the receiver method, and ``pre_log_response`` is the
corresponding response of that method. If there are no :const:`pre_log` receivers,
then the list will be empty. ``pre_log_receiver`` is guaranteed to be
non-null, but ``pre_log_response`` may be ``None``. This depends on the corresponding
``pre_log_receiver``'s return value.
"""
164 changes: 164 additions & 0 deletions auditlog_tests/tests.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import datetime
import itertools
import json
import random
import warnings
from datetime import timezone
from unittest import mock
from unittest.mock import patch

import freezegun
from dateutil.tz import gettz
Expand All @@ -27,6 +29,7 @@
from auditlog.middleware import AuditlogMiddleware
from auditlog.models import LogEntry
from auditlog.registry import AuditlogModelRegistry, AuditLogRegistrationError, auditlog
from auditlog.signals import post_log, pre_log
from auditlog_tests.fixtures.custom_get_cid import get_cid as custom_get_cid
from auditlog_tests.models import (
AdditionalDataIncludedModel,
Expand Down Expand Up @@ -1911,6 +1914,167 @@ def test_access_log(self):
self.assertEqual(log_entry.changes_dict, {})


class SignalTests(TestCase):
def setUp(self):
self.obj = SimpleModel.objects.create(text="I am not difficult.")
self.my_pre_log_data = {
"is_called": False,
"my_sender": None,
"my_instance": None,
"my_action": None,
}
self.my_post_log_data = {
"is_called": False,
"my_sender": None,
"my_instance": None,
"my_action": None,
"my_error": None,
}

def assertSignals(self, action):
self.assertTrue(
self.my_pre_log_data["is_called"], "pre_log hook receiver not called"
)
self.assertIs(self.my_pre_log_data["my_sender"], self.obj.__class__)
self.assertIs(self.my_pre_log_data["my_instance"], self.obj)
self.assertEqual(self.my_pre_log_data["my_action"], action)

self.assertTrue(
self.my_post_log_data["is_called"], "post_log hook receiver not called"
)
self.assertIs(self.my_post_log_data["my_sender"], self.obj.__class__)
self.assertIs(self.my_post_log_data["my_instance"], self.obj)
self.assertEqual(self.my_post_log_data["my_action"], action)
self.assertIsNone(self.my_post_log_data["my_error"])

def test_custom_signals(self):
my_ret_val = random.randint(0, 10000)
my_other_ret_val = random.randint(0, 10000)

def pre_log_receiver(sender, instance, action, **_kwargs):
self.my_pre_log_data["is_called"] = True
self.my_pre_log_data["my_sender"] = sender
self.my_pre_log_data["my_instance"] = instance
self.my_pre_log_data["my_action"] = action
return my_ret_val

def pre_log_receiver_extra(*_args, **_kwargs):
return my_other_ret_val

def post_log_receiver(
sender, instance, action, error, pre_log_results, **_kwargs
):
self.my_post_log_data["is_called"] = True
self.my_post_log_data["my_sender"] = sender
self.my_post_log_data["my_instance"] = instance
self.my_post_log_data["my_action"] = action
self.my_post_log_data["my_error"] = error

self.assertEqual(len(pre_log_results), 2)

found_first_result = False
found_second_result = False
for pre_log_fn, pre_log_result in pre_log_results:
if pre_log_fn is pre_log_receiver and pre_log_result == my_ret_val:
found_first_result = True
for pre_log_fn, pre_log_result in pre_log_results:
if (
pre_log_fn is pre_log_receiver_extra
and pre_log_result == my_other_ret_val
):
found_second_result = True

self.assertTrue(found_first_result)
self.assertTrue(found_second_result)

return my_ret_val

pre_log.connect(pre_log_receiver)
pre_log.connect(pre_log_receiver_extra)
post_log.connect(post_log_receiver)

self.obj = SimpleModel.objects.create(text="I am not difficult.")

self.assertSignals(LogEntry.Action.CREATE)

def test_custom_signals_update(self):
def pre_log_receiver(sender, instance, action, **_kwargs):
self.my_pre_log_data["is_called"] = True
self.my_pre_log_data["my_sender"] = sender
self.my_pre_log_data["my_instance"] = instance
self.my_pre_log_data["my_action"] = action

def post_log_receiver(sender, instance, action, error, **_kwargs):
self.my_post_log_data["is_called"] = True
self.my_post_log_data["my_sender"] = sender
self.my_post_log_data["my_instance"] = instance
self.my_post_log_data["my_action"] = action
self.my_post_log_data["my_error"] = error

pre_log.connect(pre_log_receiver)
post_log.connect(post_log_receiver)

self.obj.text = "Changed Text"
self.obj.save()

self.assertSignals(LogEntry.Action.UPDATE)

def test_custom_signals_delete(self):
def pre_log_receiver(sender, instance, action, **_kwargs):
self.my_pre_log_data["is_called"] = True
self.my_pre_log_data["my_sender"] = sender
self.my_pre_log_data["my_instance"] = instance
self.my_pre_log_data["my_action"] = action

def post_log_receiver(sender, instance, action, error, **_kwargs):
self.my_post_log_data["is_called"] = True
self.my_post_log_data["my_sender"] = sender
self.my_post_log_data["my_instance"] = instance
self.my_post_log_data["my_action"] = action
self.my_post_log_data["my_error"] = error

pre_log.connect(pre_log_receiver)
post_log.connect(post_log_receiver)

self.obj.delete()

self.assertSignals(LogEntry.Action.DELETE)

@patch("auditlog.receivers.LogEntry.objects")
def test_signals_errors(self, log_entry_objects_mock):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it would be great to test also the post_log signal here

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They're all tested already.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mean the error in post_log. Did I miss it?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, I see what you mean. Tbh, initially, I just copied the tests as-is from the other PR. I just went over it now and updated a few things and added the test you suggested.

class CustomSignalError(BaseException):
pass

def post_log_receiver(error, **_kwargs):
self.my_post_log_data["my_error"] = error

post_log.connect(post_log_receiver)

# create
error_create = CustomSignalError(LogEntry.Action.CREATE)
log_entry_objects_mock.log_create.side_effect = error_create
with self.assertRaises(CustomSignalError):
SimpleModel.objects.create(text="I am not difficult.")
self.assertEqual(self.my_post_log_data["my_error"], error_create)

# update
error_update = CustomSignalError(LogEntry.Action.UPDATE)
log_entry_objects_mock.log_create.side_effect = error_update
with self.assertRaises(CustomSignalError):
obj = SimpleModel.objects.get(pk=self.obj.pk)
obj.text = "updating"
obj.save()
self.assertEqual(self.my_post_log_data["my_error"], error_update)

# delete
error_delete = CustomSignalError(LogEntry.Action.DELETE)
log_entry_objects_mock.log_create.side_effect = error_delete
with self.assertRaises(CustomSignalError):
obj = SimpleModel.objects.get(pk=self.obj.pk)
obj.delete()
self.assertEqual(self.my_post_log_data["my_error"], error_delete)


@override_settings(AUDITLOG_DISABLE_ON_RAW_SAVE=True)
class DisableTest(TestCase):
"""
Expand Down
Loading