Skip to content

Commit

Permalink
Add 'custom_data' to auditlog
Browse files Browse the repository at this point in the history
In this update, the 'custom_data' field was added to the auditlog. It was incorporated into various sections of the admin, context, migrations, models and tests files, allowing it to be read-only in the auditlog's Admin interface, and allowing for custom data to be logged with log entries. It also includes a migration addition to account for the added field in the LogEntry model. This enhancement provides more flexibility and versatility for tracking auditlog data.
  • Loading branch information
hamedsh committed Jan 26, 2024
1 parent d00e147 commit d466c6e
Show file tree
Hide file tree
Showing 6 changed files with 85 additions and 14 deletions.
4 changes: 2 additions & 2 deletions auditlog/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,9 @@ class LogEntryAdmin(admin.ModelAdmin, LogEntryAdminMixin):
f"actor__{get_user_model().USERNAME_FIELD}",
]
list_filter = ["action", ResourceTypeFilter, CIDFilter]
readonly_fields = ["created", "resource_url", "action", "user_url", "msg"]
readonly_fields = ["created", "resource_url", "action", "user_url", "msg", "custom_data"]
fieldsets = [
(None, {"fields": ["created", "user_url", "resource_url", "cid"]}),
(None, {"fields": ["created", "user_url", "resource_url", "custom_data", "cid"]}),
(_("Changes"), {"fields": ["action", "msg"]}),
]

Expand Down
38 changes: 28 additions & 10 deletions auditlog/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from functools import partial

from django.contrib.auth import get_user_model
from django.contrib.auth.models import User
from django.db.models.signals import pre_save

from auditlog.models import LogEntry
Expand All @@ -14,25 +15,41 @@

@contextlib.contextmanager
def set_actor(actor, remote_addr=None):
yield from _set_logger_data(actor, {}, remote_addr)


@contextlib.contextmanager
def set_auditlog_custom_data(actor: User = None, remote_addr: str = None, **kwargs):
yield from _set_logger_data(actor, kwargs, remote_addr)


def _set_logger_data(actor, kwargs, remote_addr):
try:
context_data = auditlog_value.get()
except LookupError:
context_data = {}
actor = actor or context_data.get('actor')
custom_data = context_data.get('custom_data', {})
custom_data.update(kwargs)
"""Connect a signal receiver with current user attached."""
# Initialize thread local storage
context_data = {
"signal_duid": ("set_actor", time.time()),
"signal_duid": ("set_auditlog_custom_data", time.time()),
"remote_addr": remote_addr,
"custom_data": custom_data,
}
auditlog_value.set(context_data)

if actor:
context_data['actor'] = actor
token = auditlog_value.set(context_data)
# Connect signal for automatic logging
set_actor = partial(
_set_actor, user=actor, signal_duid=context_data["signal_duid"],
set_auditlog_custom_data = partial(
_set_auditlog_custom_data, user=actor, signal_duid=context_data["signal_duid"]
)
pre_save.connect(
set_actor,
set_auditlog_custom_data,
sender=LogEntry,
dispatch_uid=context_data["signal_duid"],
weak=False,
)

try:
yield
finally:
Expand All @@ -42,9 +59,10 @@ def set_actor(actor, remote_addr=None):
pass
else:
pre_save.disconnect(sender=LogEntry, dispatch_uid=auditlog["signal_duid"])
auditlog_value.reset(token)


def _set_actor(user, sender, instance, signal_duid, **kwargs):
def _set_auditlog_custom_data(user: User, sender, instance, signal_duid, **kwargs):
"""Signal receiver with extra 'user' and 'signal_duid' kwargs.
This function becomes a valid signal receiver when it is curried with the actor and a dispatch id.
Expand All @@ -64,8 +82,8 @@ def _set_actor(user, sender, instance, signal_duid, **kwargs):
):
instance.actor = user
instance.actor_email = user.email

instance.remote_addr = auditlog["remote_addr"]
instance.custom_data = auditlog["custom_data"]


@contextlib.contextmanager
Expand Down
17 changes: 17 additions & 0 deletions auditlog/migrations/0017_add_custom_data.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("auditlog", "0016_add_actor_email"),
]

operations = [
migrations.AddField(
model_name="logentry",
name="custom_data",
field=models.JSONField(
null=True, verbose_name="custom data", blank=True,
),
),
]
1 change: 1 addition & 0 deletions auditlog/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,7 @@ class Action:
blank=True, null=True, verbose_name=_("additional data")
)
actor_email = models.CharField(blank=True, null=True, max_length=254)
custom_data = models.JSONField(blank=True, null=True)

objects = LogEntryManager()

Expand Down
12 changes: 11 additions & 1 deletion auditlog_tests/test_two_step_json_migration.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,17 @@ def call_command(self, *args, **kwargs):
call_command(
"auditlogmigratejson", *args, stdout=outbuf, stderr=errbuf, **kwargs
)
return outbuf.getvalue().strip(), errbuf.getvalue().strip()
outbuf = self._remove_formatters(outbuf)
errbuf = self._remove_formatters(errbuf)
return outbuf, errbuf

@staticmethod
def _remove_formatters(outbuf):
return (outbuf.getvalue().strip()
.replace('\x1b[0m', '')
.replace('\x1b[32;1m', '')
.replace('\x1b[33;1m', '')
.replace('\x1b[31;1m', ''))

def test_nothing_to_migrate(self):
outbuf, errbuf = self.call_command()
Expand Down
27 changes: 26 additions & 1 deletion auditlog_tests/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@

from auditlog.admin import LogEntryAdmin
from auditlog.cid import get_cid
from auditlog.context import disable_auditlog, set_actor
from auditlog.context import disable_auditlog, set_actor, set_auditlog_custom_data
from auditlog.diff import model_instance_diff
from auditlog.middleware import AuditlogMiddleware
from auditlog.models import DEFAULT_OBJECT_REPR, LogEntry
Expand Down Expand Up @@ -581,6 +581,31 @@ def test_set_actor_anonymous_request(self):
)
self.assertIsNone(history.actor, msg="Actor is `None` for anonymous user")

def test_set_actor_get_email(self):
"""
The remote address will be set even when there is no actor
"""
actor = self.user

with set_actor(actor=actor):
obj = SimpleModel.objects.create(text="I am not difficult.")

history = obj.history.get()
self.assertEqual(history.actor_email, self.user.email)

def test_set_actor_set_custom_data(self):
"""
The remote address will be set even when there is no actor
"""
actor = self.user

with set_auditlog_custom_data(actor=actor, custom_data={"foo": "bar"}):
obj = SimpleModel.objects.create(text="I am not difficult.")

history = obj.history.get()
self.assertEqual(history.actor_email, self.user.email)
self.assertEqual(history.custom_data, {'custom_data': {'foo': 'bar'}})

def test_get_actor(self):
params = [
(AnonymousUser(), None, "The user is anonymous so the actor is `None`"),
Expand Down

0 comments on commit d466c6e

Please sign in to comment.