Merge upstream version 2.2.0

This commit is contained in:
Aleh Rymašeŭski 2023-07-13 15:06:56 +00:00
commit c422dd1f0d
19 changed files with 315 additions and 22 deletions

View file

@ -9,7 +9,7 @@ jobs:
fail-fast: false
max-parallel: 5
matrix:
python-version: ['3.7', '3.8', '3.9', '3.10']
python-version: ['3.7', '3.8', '3.9', '3.10', '3.11']
services:
postgres:

View file

@ -1,9 +1,15 @@
# Changes
#### Improvements
## 2.2.0 (2022-10-07)
#### 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`
to disable it during raw-save operations like loaddata. [#446](https://github.com/jazzband/django-auditlog/pull/446)
- Python: Confirm Python 3.11 support ([#447](https://github.com/jazzband/django-auditlog/pull/447))
- feat: Replace the `django.utils.timezone.utc` by `datetime.timezone.utc`. [#448](https://github.com/jazzband/django-auditlog/pull/448)
#### Fixes

View file

@ -15,3 +15,8 @@ settings.AUDITLOG_EXCLUDE_TRACKING_MODELS = getattr(
settings.AUDITLOG_INCLUDE_TRACKING_MODELS = getattr(
settings, "AUDITLOG_INCLUDE_TRACKING_MODELS", ()
)
# Disable on raw save to avoid logging imports and similar
settings.AUDITLOG_DISABLE_ON_RAW_SAVE = getattr(
settings, "AUDITLOG_DISABLE_ON_RAW_SAVE", False
)

View file

@ -64,3 +64,15 @@ def _set_actor(user, sender, instance, signal_duid, **kwargs):
instance.actor = user
instance.remote_addr = auditlog["remote_addr"]
@contextlib.contextmanager
def disable_auditlog():
threadlocal.auditlog_disabled = True
try:
yield
finally:
try:
del threadlocal.auditlog_disabled
except AttributeError:
pass

View file

@ -1,7 +1,9 @@
from datetime import timezone
from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist
from django.db.models import NOT_PROVIDED, DateTimeField, JSONField, Model
from django.utils import timezone
from django.utils import timezone as django_timezone
from django.utils.encoding import smart_str
@ -63,8 +65,12 @@ def get_field_value(obj, field):
# DateTimeFields are timezone-aware, so we need to convert the field
# to its naive form before we can accurately compare them for changes.
value = field.to_python(getattr(obj, field.name, None))
if value is not None and settings.USE_TZ and not timezone.is_naive(value):
value = timezone.make_naive(value, timezone=timezone.utc)
if (
value is not None
and settings.USE_TZ
and not django_timezone.is_naive(value)
):
value = django_timezone.make_naive(value, timezone=timezone.utc)
elif isinstance(field, JSONField):
value = field.to_python(getattr(obj, field.name, None))
else:

View file

@ -0,0 +1,21 @@
# Generated by Django 4.1.1 on 2022-10-13 07:56
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("auditlog", "0013_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",
),
),
]

View file

@ -15,6 +15,7 @@ from django.utils.timezone import localtime
from auditlog.models import LogEntry
from auditlog.registry import auditlog
from auditlog.signals import accessed
MAX = 75
@ -53,7 +54,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"
@ -177,3 +178,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)

View file

@ -1,6 +1,7 @@
import ast
import json
from copy import deepcopy
from datetime import timezone
from typing import Any, Dict, List
from dateutil import parser
@ -12,7 +13,7 @@ from django.core import serializers
from django.core.exceptions import FieldDoesNotExist
from django.db import DEFAULT_DB_ALIAS, models
from django.db.models import Q, QuerySet
from django.utils import formats, timezone
from django.utils import formats
from django.utils.encoding import smart_str
from django.utils.translation import gettext_lazy as _
@ -308,17 +309,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(

View file

@ -1,9 +1,31 @@
import json
from functools import wraps
from django.conf import settings
from auditlog.context import threadlocal
from auditlog.diff import model_instance_diff
from auditlog.models import LogEntry
def check_disable(signal_handler):
"""
Decorator that passes along disabled in kwargs if any of the following is true:
- 'auditlog_disabled' from threadlocal is true
- raw = True and AUDITLOG_DISABLE_ON_RAW_SAVE is True
"""
@wraps(signal_handler)
def wrapper(*args, **kwargs):
if not getattr(threadlocal, "auditlog_disabled", False) and not (
kwargs.get("raw") and settings.AUDITLOG_DISABLE_ON_RAW_SAVE
):
signal_handler(*args, **kwargs)
return wrapper
@check_disable
def log_create(sender, instance, created, **kwargs):
"""
Signal receiver that creates a log entry when a model instance is first saved to the database.
@ -20,6 +42,7 @@ def log_create(sender, instance, created, **kwargs):
)
@check_disable
def log_update(sender, instance, **kwargs):
"""
Signal receiver that creates a log entry when a model instance is changed and saved to the database.
@ -45,6 +68,7 @@ def log_update(sender, instance, **kwargs):
)
@check_disable
def log_delete(sender, instance, **kwargs):
"""
Signal receiver that creates a log entry when a model instance is deleted from the database.
@ -61,9 +85,25 @@ 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."""
@check_disable
def log_m2m_changes(signal, action, **kwargs):
"""Handle m2m_changed and call LogEntry.objects.log_m2m_changes as needed."""
if action not in ["post_add", "post_clear", "post_remove"]:

View file

@ -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:
@ -267,7 +271,8 @@ class AuditlogModelRegistry:
"""
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_DISABLE_ON_RAW_SAVE, bool):
raise TypeError("Setting 'AUDITLOG_DISABLE_ON_RAW_SAVE' 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"

3
auditlog/signals.py Normal file
View file

@ -0,0 +1,3 @@
import django.dispatch
accessed = django.dispatch.Signal()

View file

@ -0,0 +1,15 @@
[
{
"model": "auditlog_tests.manyrelatedmodel",
"pk": 1,
"fields": {
"recursive": [1],
"related": [1]
}
},
{
"model": "auditlog_tests.manyrelatedothermodel",
"pk": 1,
"fields": {}
}
]

View file

@ -2,6 +2,7 @@ import datetime
import itertools
import json
import warnings
from datetime import timezone
from unittest import mock
import freezegun
@ -12,12 +13,15 @@ from django.contrib.admin.sites import AdminSite
from django.contrib.auth import get_user_model
from django.contrib.auth.models import AnonymousUser, User
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.utils import dateformat, formats, timezone
from django.urls import reverse
from django.utils import dateformat, formats
from django.utils import timezone as django_timezone
from auditlog.admin import LogEntryAdmin
from auditlog.context import set_actor
from auditlog.context import disable_auditlog, set_actor
from auditlog.diff import model_instance_diff
from auditlog.middleware import AuditlogMiddleware
from auditlog.models import LogEntry
@ -616,8 +620,8 @@ class AdditionalDataModelTest(TestCase):
class DateTimeFieldModelTest(TestCase):
"""Tests if DateTimeField changes are recognised correctly"""
utc_plus_one = timezone.get_fixed_timezone(datetime.timedelta(hours=1))
now = timezone.now()
utc_plus_one = django_timezone.get_fixed_timezone(datetime.timedelta(hours=1))
now = django_timezone.now()
def setUp(self):
super().setUp()
@ -786,7 +790,7 @@ class DateTimeFieldModelTest(TestCase):
" DATETIME_FORMAT"
),
)
timestamp = timezone.now()
timestamp = django_timezone.now()
dtm.timestamp = timestamp
dtm.save()
localized_timestamp = timestamp.astimezone(gettz(settings.TIME_ZONE))
@ -910,7 +914,9 @@ class DateTimeFieldModelTest(TestCase):
dtm.save()
# Change with naive field doesnt raise error
dtm.naive_dt = timezone.make_naive(timezone.now(), timezone=timezone.utc)
dtm.naive_dt = django_timezone.make_naive(
django_timezone.now(), timezone=timezone.utc
)
dtm.save()
@ -1092,6 +1098,12 @@ class RegisterModelSettingsTest(TestCase):
):
self.test_auditlog.register_from_settings()
with override_settings(AUDITLOG_DISABLE_ON_RAW_SAVE="bad value"):
with self.assertRaisesMessage(
TypeError, "Setting 'AUDITLOG_DISABLE_ON_RAW_SAVE' must be a boolean"
):
self.test_auditlog.register_from_settings()
@override_settings(
AUDITLOG_INCLUDE_ALL_MODELS=True,
AUDITLOG_EXCLUDE_TRACKING_MODELS=("auditlog_tests.SimpleExcludeModel",),
@ -1579,7 +1591,7 @@ class ModelInstanceDiffTest(TestCase):
class TestModelSerialization(TestCase):
def setUp(self):
super().setUp()
self.test_date = datetime.datetime(2022, 1, 1, 12, tzinfo=datetime.timezone.utc)
self.test_date = datetime.datetime(2022, 1, 1, 12, tzinfo=timezone.utc)
self.test_date_string = datetime.datetime.strftime(
self.test_date, "%Y-%m-%dT%XZ"
)
@ -1796,3 +1808,87 @@ class TestModelSerialization(TestCase):
"value": 11,
},
)
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):
"""
All the other tests check logging, so this only needs to test disabled logging.
"""
def test_create(self):
# Mimic the way imports create objects
inst = SimpleModel(
text="I am a bit more difficult.",
boolean=False,
datetime=django_timezone.now(),
)
SimpleModel.save_base(inst, raw=True)
self.assertEqual(0, LogEntry.objects.get_for_object(inst).count())
def test_create_with_context_manager(self):
with disable_auditlog():
inst = SimpleModel.objects.create(text="I am a bit more difficult.")
self.assertEqual(0, LogEntry.objects.get_for_object(inst).count())
def test_update(self):
inst = SimpleModel(
text="I am a bit more difficult.",
boolean=False,
datetime=django_timezone.now(),
)
SimpleModel.save_base(inst, raw=True)
inst.text = "I feel refreshed"
inst.save_base(raw=True)
self.assertEqual(0, LogEntry.objects.get_for_object(inst).count())
def test_update_with_context_manager(self):
inst = SimpleModel(
text="I am a bit more difficult.",
boolean=False,
datetime=django_timezone.now(),
)
SimpleModel.save_base(inst, raw=True)
with disable_auditlog():
inst.text = "I feel refreshed"
inst.save()
self.assertEqual(0, LogEntry.objects.get_for_object(inst).count())
def test_m2m(self):
"""
Create m2m from fixture and check that nothing was logged.
This only works with context manager
"""
with disable_auditlog():
management.call_command("loaddata", "m2m_test_fixture.json", verbosity=0)
recursive = ManyRelatedModel.objects.get(pk=1)
self.assertEqual(0, LogEntry.objects.get_for_object(recursive).count())
related = ManyRelatedOtherModel.objects.get(pk=1)
self.assertEqual(0, LogEntry.objects.get_for_object(related).count())

View file

@ -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/<int:pk>/",
SimpleModelDetailview.as_view(),
name="simplemodel-detail",
),
]

9
auditlog_tests/views.py Normal file
View file

@ -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"

View file

@ -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.
@ -201,6 +220,18 @@ It must be a list or tuple. Each item in this setting can be a:
.. versionadded:: 2.1.0
**AUDITLOG_DISABLE_ON_RAW_SAVE**
Disables logging during raw save. (I.e. for instance using loaddata)
.. note::
M2M operations will still be logged, since they're never considered `raw`. To disable them
you must remove their setting or use the `disable_auditlog` context manager.
.. versionadded:: 2.2.0
Actors
------
@ -228,10 +259,11 @@ It is recommended to keep all middleware that alters the request loaded before A
user as actor. To only have some object changes to be logged with the current request's user as actor manual logging is
required.
Context manager
***************
Context managers
----------------
.. versionadded:: 2.1.0
Set actor
*********
To enable the automatic logging of the actors outside of request context (e.g. in a Celery task), you can use a context
manager::
@ -244,6 +276,26 @@ manager::
# if your code here leads to creation of LogEntry instances, these will have the actor set
...
.. versionadded:: 2.1.0
Disable auditlog
****************
Disable auditlog temporary, for instance if you need to install a large fixture on a live system or cleanup
corrupt data::
from auditlog.context import disable_auditlog
with disable_auditlog():
# Do things silently here
...
.. versionadded:: 2.2.0
Object history
--------------

View file

@ -40,6 +40,7 @@ setup(
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Framework :: Django",
"Framework :: Django :: 3.2",
"Framework :: Django :: 4.0",

View file

@ -1,7 +1,8 @@
[tox]
envlist =
{py37,py38,py39,py310}-django32
{py38,py39,py310}-django{40,41,main}
{py38,py39,py310}-django40
{py38,py39,py310,py311}-django{41,main}
py37-docs
py38-lint
@ -21,7 +22,7 @@ deps =
codecov
django-multiselectfield
freezegun
psycopg2-binary==2.8.6
psycopg2-binary
passenv=
TEST_DB_HOST
TEST_DB_USER
@ -30,6 +31,7 @@ passenv=
TEST_DB_PORT
basepython =
py311: python3.11
py310: python3.10
py39: python3.9
py38: python3.8
@ -51,3 +53,4 @@ python =
3.8: py38
3.9: py39
3.10: py310
3.11: py311