diff --git a/CHANGELOG.md b/CHANGELOG.md index d87b91e..f31dc4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ # Changes #### Improvements - +- feat: Add `ACCESS` action to `LogEntry` model and allow object access to be logged. ([#436](https://github.com/jazzband/django-auditlog/pull/436)) - feat: Add `serialized_data` field on `LogEntry` model. ([#412](https://github.com/jazzband/django-auditlog/pull/412)) - feat: Display the field name as it would be displayed in Django Admin or use `mapping_field` if available [#428](https://github.com/jazzband/django-auditlog/pull/428) - feat: New context manager `disable_auditlog` to turn off logging and a new setting `AUDITLOG_DISABLE_ON_RAW_SAVE` diff --git a/auditlog/migrations/0012_add_logentry_action_access.py b/auditlog/migrations/0012_add_logentry_action_access.py new file mode 100644 index 0000000..7627941 --- /dev/null +++ b/auditlog/migrations/0012_add_logentry_action_access.py @@ -0,0 +1,22 @@ +# Generated by Django 4.1.1 on 2022-10-13 07:56 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("auditlog", "0011_logentry_serialized_data"), + ] + + operations = [ + migrations.AlterField( + model_name="logentry", + name="action", + field=models.PositiveSmallIntegerField( + choices=[(0, "create"), (1, "update"), (2, "delete"), (3, "access")], + db_index=True, + verbose_name="action", + ), + ), + ] diff --git a/auditlog/mixins.py b/auditlog/mixins.py index efa1ab5..5636480 100644 --- a/auditlog/mixins.py +++ b/auditlog/mixins.py @@ -12,6 +12,7 @@ from django.utils.timezone import localtime from auditlog.models import LogEntry from auditlog.registry import auditlog +from auditlog.signals import accessed MAX = 75 @@ -50,7 +51,7 @@ class LogEntryAdminMixin: @admin.display(description="Changes") def msg_short(self, obj): - if obj.action == LogEntry.Action.DELETE: + if obj.action in [LogEntry.Action.DELETE, LogEntry.Action.ACCESS]: return "" # delete changes = json.loads(obj.changes) s = "" if len(changes) == 1 else "s" @@ -137,3 +138,10 @@ class LogEntryAdminMixin: return pretty_name(getattr(field, "verbose_name", field_name)) except FieldDoesNotExist: return pretty_name(field_name) + + +class LogAccessMixin: + def render_to_response(self, context, **response_kwargs): + obj = self.get_object() + accessed.send(obj.__class__, instance=obj) + return super().render_to_response(context, **response_kwargs) diff --git a/auditlog/models.py b/auditlog/models.py index 63c154f..aefc56a 100644 --- a/auditlog/models.py +++ b/auditlog/models.py @@ -308,17 +308,20 @@ class LogEntry(models.Model): action. This may be useful in some cases when comparing actions because the ``__lt``, ``__lte``, ``__gt``, ``__gte`` lookup filters can be used in queries. - The valid actions are :py:attr:`Action.CREATE`, :py:attr:`Action.UPDATE` and :py:attr:`Action.DELETE`. + The valid actions are :py:attr:`Action.CREATE`, :py:attr:`Action.UPDATE`, + :py:attr:`Action.DELETE` and :py:attr:`Action.ACCESS`. """ CREATE = 0 UPDATE = 1 DELETE = 2 + ACCESS = 3 choices = ( (CREATE, _("create")), (UPDATE, _("update")), (DELETE, _("delete")), + (ACCESS, _("access")), ) content_type = models.ForeignKey( diff --git a/auditlog/receivers.py b/auditlog/receivers.py index 8117870..2a2c475 100644 --- a/auditlog/receivers.py +++ b/auditlog/receivers.py @@ -85,6 +85,21 @@ def log_delete(sender, instance, **kwargs): ) +def log_access(sender, instance, **kwargs): + """ + Signal receiver that creates a log entry when a model instance is accessed in a AccessLogDetailView. + + 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, + action=LogEntry.Action.ACCESS, + changes="null", + ) + + def make_log_m2m_changes(field_name): """Return a handler for m2m_changed with field_name enclosed.""" diff --git a/auditlog/registry.py b/auditlog/registry.py index 3bf3653..0d76455 100644 --- a/auditlog/registry.py +++ b/auditlog/registry.py @@ -24,6 +24,7 @@ from django.db.models.signals import ( ) from auditlog.conf import settings +from auditlog.signals import accessed DispatchUID = Tuple[int, int, int] @@ -44,10 +45,11 @@ class AuditlogModelRegistry: create: bool = True, update: bool = True, delete: bool = True, + access: bool = True, m2m: bool = True, custom: Optional[Dict[ModelSignal, Callable]] = None, ): - from auditlog.receivers import log_create, log_delete, log_update + from auditlog.receivers import log_access, log_create, log_delete, log_update self._registry = {} self._signals = {} @@ -59,6 +61,8 @@ class AuditlogModelRegistry: self._signals[pre_save] = log_update if delete: self._signals[post_delete] = log_delete + if access: + self._signals[accessed] = log_access self._m2m = m2m if custom is not None: diff --git a/auditlog/signals.py b/auditlog/signals.py new file mode 100644 index 0000000..67e518c --- /dev/null +++ b/auditlog/signals.py @@ -0,0 +1,3 @@ +import django.dispatch + +accessed = django.dispatch.Signal() diff --git a/auditlog_tests/templates/simplemodel_detail.html b/auditlog_tests/templates/simplemodel_detail.html new file mode 100644 index 0000000..e69de29 diff --git a/auditlog_tests/tests.py b/auditlog_tests/tests.py index b43faaa..3f60f05 100644 --- a/auditlog_tests/tests.py +++ b/auditlog_tests/tests.py @@ -15,6 +15,7 @@ from django.contrib.contenttypes.models import ContentType from django.core import management from django.db.models.signals import pre_save from django.test import RequestFactory, TestCase, override_settings +from django.urls import reverse from django.utils import dateformat, formats, timezone from auditlog.admin import LogEntryAdmin @@ -1806,6 +1807,33 @@ class TestModelSerialization(TestCase): ) +class TestAccessLog(TestCase): + def setUp(self): + self.user = User.objects.create_user(username="test_user", is_active=True) + self.obj = SimpleModel.objects.create(text="For admin logentry test") + + def test_access_log(self): + self.client.force_login(self.user) + content_type = ContentType.objects.get_for_model(self.obj.__class__) + + # Check for log entries + qs = LogEntry.objects.filter(content_type=content_type, object_pk=self.obj.pk) + old_count = qs.count() + + self.client.get(reverse("simplemodel-detail", args=[self.obj.pk])) + new_count = qs.count() + self.assertEqual(new_count, old_count + 1) + + log_entry = qs.latest() + self.assertEqual(int(log_entry.object_pk), self.obj.pk) + self.assertEqual(log_entry.actor, self.user) + self.assertEqual(log_entry.content_type, content_type) + self.assertEqual( + log_entry.action, LogEntry.Action.ACCESS, msg="Action is 'ACCESS'" + ) + self.assertEqual(log_entry.changes, "null") + + @override_settings(AUDITLOG_DISABLE_ON_RAW_SAVE=True) class DisableTest(TestCase): """ diff --git a/auditlog_tests/urls.py b/auditlog_tests/urls.py index 083932c..712071d 100644 --- a/auditlog_tests/urls.py +++ b/auditlog_tests/urls.py @@ -1,6 +1,13 @@ from django.contrib import admin from django.urls import path +from auditlog_tests.views import SimpleModelDetailview + urlpatterns = [ path("admin/", admin.site.urls), + path( + "simplemodel//", + SimpleModelDetailview.as_view(), + name="simplemodel-detail", + ), ] diff --git a/auditlog_tests/views.py b/auditlog_tests/views.py new file mode 100644 index 0000000..436ecbf --- /dev/null +++ b/auditlog_tests/views.py @@ -0,0 +1,9 @@ +from django.views.generic import DetailView + +from auditlog.mixins import LogAccessMixin +from auditlog_tests.models import SimpleModel + + +class SimpleModelDetailview(LogAccessMixin, DetailView): + model = SimpleModel + template_name = "simplemodel_detail.html" diff --git a/docs/source/usage.rst b/docs/source/usage.rst index 0675283..9e81641 100644 --- a/docs/source/usage.rst +++ b/docs/source/usage.rst @@ -37,6 +37,25 @@ It is recommended to place the register code (``auditlog.register(MyModel)``) at This ensures that every time your model is imported it will also be registered to log changes. Auditlog makes sure that each model is only registered once, otherwise duplicate log entries would occur. + +**Logging access** + +By default, Auditlog will only log changes to your model instances. If you want to log access to your model instances as well, Auditlog provides a mixin class for that purpose. Simply add the :py:class:`auditlog.mixins.LogAccessMixin` to your class based view and Auditlog will log access to your model instances. The mixin expects your view to have a ``get_object`` method that returns the model instance for which access shall be logged - this is usually the case for DetailViews and UpdateViews. + +A DetailView utilizing the LogAccessMixin could look like the following example: + +.. code-block:: python + + from django.views.generic import DetailView + + from auditlog.mixins import LogAccessMixin + + class MyModelDetailView(LogAccessMixin, DetailView): + model = MyModel + + # View code goes here + + **Excluding fields** Fields that are excluded will not trigger saving a new log entry and will not show up in the recorded changes.