Merge branch 'master' into logentry-custom-model

This commit is contained in:
mostafaeftekharizadeh 2025-10-22 13:12:28 +03:30 committed by alimohammadiiii
commit 7e4ee64140
18 changed files with 313 additions and 57 deletions

View file

@ -18,7 +18,7 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.9'
python-version: '3.10'
- name: Get pip cache dir
id: pip-cache

View file

@ -9,7 +9,7 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
python-version: ["3.10", "3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v5
@ -35,7 +35,7 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
python-version: ["3.10", "3.11", "3.12", "3.13"]
services:
postgres:
image: postgres:15
@ -81,7 +81,7 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
python-version: ["3.10", "3.11", "3.12", "3.13"]
services:
mysql:
image: mysql:8.0

View file

@ -4,24 +4,24 @@ repos:
rev: 25.9.0
hooks:
- id: black
language_version: python3.9
language_version: python3.10
args:
- "--target-version"
- "py39"
- "py310"
- repo: https://github.com/PyCQA/flake8
rev: "7.3.0"
hooks:
- id: flake8
args: ["--max-line-length", "110"]
- repo: https://github.com/PyCQA/isort
rev: 6.1.0
rev: 7.0.0
hooks:
- id: isort
- repo: https://github.com/asottile/pyupgrade
rev: v3.20.0
rev: v3.21.0
hooks:
- id: pyupgrade
args: [--py39-plus]
args: [--py310-plus]
- repo: https://github.com/adamchainz/django-upgrade
rev: 1.29.0
hooks:

View file

@ -2,6 +2,11 @@
## Next Release
#### Improvements
- Add `AUDITLOG_USE_BASE_MANAGER` setting to override default manager use ([#766](https://github.com/jazzband/django-auditlog/pull/766))
- Drop 'Python 3.9' support ([#773](https://github.com/jazzband/django-auditlog/pull/773))
## 3.3.0 (2025-09-18)
#### Improvements

View file

@ -1,5 +1,4 @@
from contextvars import ContextVar
from typing import Optional
from django.conf import settings
from django.http import HttpRequest
@ -8,7 +7,7 @@ from django.utils.module_loading import import_string
correlation_id = ContextVar("auditlog_correlation_id", default=None)
def set_cid(request: Optional[HttpRequest] = None) -> None:
def set_cid(request: HttpRequest | None = None) -> None:
"""
A function to read the cid from a request.
If the header is not in the request, then we set it to `None`.
@ -40,11 +39,11 @@ def set_cid(request: Optional[HttpRequest] = None) -> None:
correlation_id.set(cid)
def _get_cid() -> Optional[str]:
def _get_cid() -> str | None:
return correlation_id.get()
def get_cid() -> Optional[str]:
def get_cid() -> str | None:
"""
Calls the cid getter function based on `settings.AUDITLOG_CID_GETTER`

View file

@ -66,3 +66,8 @@ settings.AUDITLOG_MASK_CALLABLE = getattr(settings, "AUDITLOG_MASK_CALLABLE", No
settings.AUDITLOG_LOGENTRY_MODEL = getattr(
settings, "AUDITLOG_LOGENTRY_MODEL", "auditlog.LogEntry"
)
# Use base model managers instead of default model managers
settings.AUDITLOG_USE_BASE_MANAGER = getattr(
settings, "AUDITLOG_USE_BASE_MANAGER", False
)

View file

@ -1,6 +1,6 @@
import json
from collections.abc import Callable
from datetime import timezone
from typing import Callable, Optional
from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist
@ -132,7 +132,7 @@ def is_primitive(obj) -> bool:
return isinstance(obj, primitive_types)
def get_mask_function(mask_callable: Optional[str] = None) -> Callable[[str], str]:
def get_mask_function(mask_callable: str | None = None) -> Callable[[str], str]:
"""
Get the masking function to use based on the following priority:
1. Model-specific mask_callable if provided
@ -169,8 +169,8 @@ def mask_str(value: str) -> str:
def model_instance_diff(
old: Optional[Model],
new: Optional[Model],
old: Model | None,
new: Model | None,
fields_to_check=None,
use_json_for_changes=False,
):

View file

@ -1,5 +1,3 @@
from typing import Optional
from django.conf import settings
from django.contrib.auth import get_user_model
@ -39,7 +37,7 @@ class AuditlogMiddleware:
return remote_addr
@staticmethod
def _get_remote_port(request) -> Optional[int]:
def _get_remote_port(request) -> int | None:
remote_port = request.headers.get("X-Forwarded-Port", "")
try:
@ -70,4 +68,3 @@ class AuditlogMiddleware:
with set_extra_data(context_data=self.get_extra_data(request)):
return self.get_response(request)

View file

@ -1,9 +1,10 @@
import ast
import contextlib
import json
from collections.abc import Callable
from copy import deepcopy
from datetime import timezone
from typing import Any, Callable, Union
from typing import Any
from dateutil import parser
from dateutil.tz import gettz
@ -536,7 +537,7 @@ class AbstractLogEntry(models.Model):
return changes_display_dict
def _get_changes_display_for_fk_field(
self, field: Union[models.ForeignKey, models.OneToOneField], value: Any
self, field: models.ForeignKey | models.OneToOneField, value: Any
) -> str:
"""
:return: A string representing a given FK value and the field to which it belongs
@ -555,7 +556,9 @@ class AbstractLogEntry(models.Model):
return value
# Attempt to return the string representation of the object
try:
return smart_str(field.related_model._default_manager.get(pk=pk_value))
related_model_manager = _get_manager_from_settings(field.related_model)
return smart_str(related_model_manager.get(pk=pk_value))
# ObjectDoesNotExist will be raised if the object was deleted.
except ObjectDoesNotExist:
return f"Deleted '{field.related_model.__name__}' ({value})"
@ -629,3 +632,16 @@ def _changes_func() -> Callable[[LogEntry], dict]:
if settings.AUDITLOG_USE_TEXT_CHANGES_IF_JSON_IS_NOT_PRESENT:
return json_then_text
return default
def _get_manager_from_settings(model: type[models.Model]) -> models.Manager:
"""
Get model manager as selected by AUDITLOG_USE_BASE_MANAGER.
- True: return model._meta.base_manager
- False: return model._meta.default_manager
"""
if settings.AUDITLOG_USE_BASE_MANAGER:
return model._meta.base_manager
else:
return model._meta.default_manager

View file

@ -5,6 +5,7 @@ from django.conf import settings
from auditlog import get_logentry_model
from auditlog.context import auditlog_disabled
from auditlog.diff import model_instance_diff
from auditlog.models import _get_manager_from_settings
from auditlog.signals import post_log, pre_log
@ -56,7 +57,7 @@ def log_update(sender, instance, **kwargs):
"""
if not instance._state.adding and instance.pk is not None:
update_fields = kwargs.get("update_fields", None)
old = sender._default_manager.filter(pk=instance.pk).first()
old = _get_manager_from_settings(sender).filter(pk=instance.pk).first()
_create_log_entry(
action=get_logentry_model().Action.UPDATE,
instance=instance,
@ -172,12 +173,12 @@ def make_log_m2m_changes(field_name):
return
LogEntry = get_logentry_model()
model_manager = _get_manager_from_settings(kwargs["model"])
if action == "post_clear":
changed_queryset = kwargs["model"]._default_manager.all()
changed_queryset = model_manager.all()
else:
changed_queryset = kwargs["model"]._default_manager.filter(
pk__in=kwargs["pk_set"]
)
changed_queryset = model_manager.filter(pk__in=kwargs["pk_set"])
if action in ["post_add"]:
LogEntry.objects.log_m2m_changes(

View file

@ -1,7 +1,7 @@
import copy
from collections import defaultdict
from collections.abc import Collection, Iterable
from typing import Any, Callable, Optional, Union
from collections.abc import Callable, Collection, Iterable
from typing import Any
from django.apps import apps
from django.db.models import ManyToManyField, Model
@ -38,7 +38,7 @@ class AuditlogModelRegistry:
delete: bool = True,
access: bool = True,
m2m: bool = True,
custom: Optional[dict[ModelSignal, Callable]] = None,
custom: dict[ModelSignal, Callable] | None = None,
):
from auditlog.receivers import log_access, log_create, log_delete, log_update
@ -62,14 +62,14 @@ class AuditlogModelRegistry:
def register(
self,
model: ModelBase = None,
include_fields: Optional[list[str]] = None,
exclude_fields: Optional[list[str]] = None,
mapping_fields: Optional[dict[str, str]] = None,
mask_fields: Optional[list[str]] = None,
mask_callable: Optional[str] = None,
m2m_fields: Optional[Collection[str]] = None,
include_fields: list[str] | None = None,
exclude_fields: list[str] | None = None,
mapping_fields: dict[str, str] | None = None,
mask_fields: list[str] | None = None,
mask_callable: str | None = None,
m2m_fields: Collection[str] | None = None,
serialize_data: bool = False,
serialize_kwargs: Optional[dict[str, Any]] = None,
serialize_kwargs: dict[str, Any] | None = None,
serialize_auditlog_fields_only: bool = False,
):
"""
@ -259,7 +259,7 @@ class AuditlogModelRegistry:
]
return exclude_models
def _register_models(self, models: Iterable[Union[str, dict[str, Any]]]) -> None:
def _register_models(self, models: Iterable[str | dict[str, Any]]) -> None:
models = copy.deepcopy(models)
for model in models:
if isinstance(model, str):

0
auditlog_tests/auditlog Normal file
View file

View file

@ -430,6 +430,40 @@ class SwappedManagerModel(models.Model):
objects = SecretManager()
def __str__(self):
return str(self.name)
@auditlog.register()
class SecretRelatedModel(RelatedModelParent):
"""
A RelatedModel, but with a foreign key to an object that could be secret.
"""
related = models.ForeignKey(
"SwappedManagerModel", related_name="related_models", on_delete=models.CASCADE
)
one_to_one = models.OneToOneField(
to="SwappedManagerModel",
on_delete=models.CASCADE,
related_name="reverse_one_to_one",
)
history = AuditlogHistoryField(delete_related=True)
def __str__(self):
return f"SecretRelatedModel #{self.pk} -> {self.related.id}"
class SecretM2MModel(models.Model):
m2m_related = models.ManyToManyField(
"SwappedManagerModel", related_name="m2m_related"
)
name = models.CharField(max_length=255)
def __str__(self):
return str(self.name)
class AutoManyRelatedModel(models.Model):
related = models.ManyToManyField(SimpleModel)
@ -459,6 +493,8 @@ auditlog.register(ManyRelatedModel.recursive.through)
m2m_only_auditlog.register(ManyRelatedModel, m2m_fields={"related"})
m2m_only_auditlog.register(ModelForReusableThroughModel, m2m_fields={"related"})
m2m_only_auditlog.register(OtherModelForReusableThroughModel, m2m_fields={"related"})
m2m_only_auditlog.register(SecretM2MModel, m2m_fields={"m2m_related"})
m2m_only_auditlog.register(SwappedManagerModel, m2m_fields={"m2m_related"})
auditlog.register(SimpleExcludeModel, exclude_fields=["text"])
auditlog.register(SimpleMappingModel, mapping_fields={"sku": "Product No."})
auditlog.register(AdditionalDataIncludedModel)

View file

@ -48,6 +48,8 @@ from test_app.models import (
ProxyModel,
RelatedModel,
ReusableThroughRelatedModel,
SecretM2MModel,
SecretRelatedModel,
SerializeNaturalKeyRelatedModel,
SerializeOnlySomeOfThisModel,
SerializePrimaryKeyRelatedModel,
@ -1369,7 +1371,7 @@ class RegisterModelSettingsTest(TestCase):
self.assertTrue(self.test_auditlog.contains(SimpleExcludeModel))
self.assertTrue(self.test_auditlog.contains(ChoicesFieldModel))
self.assertEqual(len(self.test_auditlog.get_models()), 34)
self.assertEqual(len(self.test_auditlog.get_models()), 36)
def test_register_models_register_model_with_attrs(self):
self.test_auditlog._register_models(
@ -2945,6 +2947,136 @@ class ModelManagerTest(TestCase):
self.assertEqual(log.changes_dict["name"], ["Public", "Updated"])
class BaseManagerSettingTest(TestCase):
"""
If the AUDITLOG_USE_BASE_MANAGER setting is enabled, "secret" objects
should be audited as if they were public, with full access to field
values.
"""
def test_use_base_manager_setting_update(self):
"""
Model update. The default False case is covered by test_update_secret.
"""
secret = SwappedManagerModel.objects.create(is_secret=True, name="Secret")
with override_settings(AUDITLOG_USE_BASE_MANAGER=True):
secret.name = "Updated"
secret.save()
log = LogEntry.objects.get_for_object(secret).first()
self.assertEqual(log.action, LogEntry.Action.UPDATE)
self.assertEqual(log.changes_dict["name"], ["Secret", "Updated"])
def test_use_base_manager_setting_related_model(self):
"""
When AUDITLOG_USE_BASE_MANAGER is enabled, related model changes that
are normally invisible to the default model manager should remain
visible and not refer to "deleted" objects.
"""
t1 = datetime.datetime(2025, 1, 1, 12, tzinfo=datetime.timezone.utc)
with (
override_settings(AUDITLOG_USE_BASE_MANAGER=False),
freezegun.freeze_time(t1),
):
public_one = SwappedManagerModel.objects.create(name="Public One")
secret_one = SwappedManagerModel.objects.create(
is_secret=True, name="Secret One"
)
instance_one = SecretRelatedModel.objects.create(
one_to_one=public_one,
related=secret_one,
)
log_one = instance_one.history.filter(timestamp=t1).first()
self.assertIsInstance(log_one, LogEntry)
display_dict = log_one.changes_display_dict
self.assertEqual(display_dict["related"][0], "None")
self.assertEqual(
display_dict["related"][1],
f"Deleted 'SwappedManagerModel' ({secret_one.id})",
"Default manager should have no visibility of secret object",
)
self.assertEqual(display_dict["one to one"][0], "None")
self.assertEqual(display_dict["one to one"][1], "Public One")
t2 = t1 + datetime.timedelta(days=20)
with (
override_settings(AUDITLOG_USE_BASE_MANAGER=True),
freezegun.freeze_time(t2),
):
public_two = SwappedManagerModel.objects.create(name="Public Two")
secret_two = SwappedManagerModel.objects.create(
is_secret=True, name="Secret Two"
)
instance_two = SecretRelatedModel.objects.create(
one_to_one=public_two,
related=secret_two,
)
log_two = instance_two.history.filter(timestamp=t2).first()
self.assertIsInstance(log_two, LogEntry)
display_dict = log_two.changes_display_dict
self.assertEqual(display_dict["related"][0], "None")
self.assertEqual(
display_dict["related"][1],
"Secret Two",
"Base manager should have full visibility of secret object",
)
self.assertEqual(display_dict["one to one"][0], "None")
self.assertEqual(display_dict["one to one"][1], "Public Two")
def test_use_base_manager_setting_changes(self):
"""
When AUDITLOG_USE_BASE_MANAGER is enabled, registered many-to-many model
changes that refer to an object hidden from the default model manager
should remain visible and be logged.
"""
with override_settings(AUDITLOG_USE_BASE_MANAGER=False):
obj_one = SwappedManagerModel.objects.create(
is_secret=True, name="Secret One"
)
m2m_one = SecretM2MModel.objects.create(name="M2M One")
m2m_one.m2m_related.add(obj_one)
self.assertIn(m2m_one, obj_one.m2m_related.all(), "Secret One sees M2M One")
self.assertNotIn(
obj_one, m2m_one.m2m_related.all(), "M2M One cannot see Secret One"
)
self.assertEqual(
0,
LogEntry.objects.get_for_object(m2m_one).count(),
"No update with default manager",
)
with override_settings(AUDITLOG_USE_BASE_MANAGER=True):
obj_two = SwappedManagerModel.objects.create(
is_secret=True, name="Secret Two"
)
m2m_two = SecretM2MModel.objects.create(name="M2M Two")
m2m_two.m2m_related.add(obj_two)
self.assertIn(m2m_two, obj_two.m2m_related.all(), "Secret Two sees M2M Two")
self.assertNotIn(
obj_two, m2m_two.m2m_related.all(), "M2M Two cannot see Secret Two"
)
self.assertEqual(
1,
LogEntry.objects.get_for_object(m2m_two).count(),
"Update logged with base manager",
)
log_entry = LogEntry.objects.get_for_object(m2m_two).first()
self.assertEqual(
log_entry.changes,
{
"m2m_related": {
"type": "m2m",
"operation": "add",
"objects": [smart_str(obj_two)],
}
},
)
class TestMaskStr(TestCase):
"""Test the mask_str function that masks sensitive data."""

View file

@ -11,10 +11,10 @@ The repository can be found at https://github.com/jazzband/django-auditlog/.
**Requirements**
- Python 3.9 or higher
- Python 3.10 or higher
- Django 4.2, 5.0, 5.1, and 5.2
Auditlog is currently tested with Python 3.9+ and Django 4.2, 5.0, 5.1, and 5.2. The latest test report can be found
Auditlog is currently tested with Python 3.10+ and Django 4.2, 5.0, 5.1, and 5.2. The latest test report can be found
at https://github.com/jazzband/django-auditlog/actions.
Adding Auditlog to your Django application

View file

@ -141,7 +141,7 @@ For example, to use a custom masking function::
# In your_app/utils.py
def custom_mask(value: str) -> str:
return "****" + value[-4:] # Only show last 4 characters
# In your models.py
auditlog.register(
MyModel,
@ -270,13 +270,13 @@ It will be considered when ``AUDITLOG_DISABLE_REMOTE_ADDR`` is `True`.
You can use this setting to mask specific field values in all tracked models
while still logging changes. This is useful when models contain sensitive fields
like `password`, `api_key`, or `secret_token`` that should not be logged
like `password`, `api_key`, or `secret_token` that should not be logged
in plain text but need to be auditable.
When a masked field changes, its value will be replaced with a masked
representation (e.g., `****`) in the audit log instead of storing the actual value.
This setting will be applied only when `AUDITLOG_INCLUDE_ALL_MODELS`` is `True`.
This setting will be applied only when ``AUDITLOG_INCLUDE_ALL_MODELS`` is `True`.
.. code-block:: python
@ -377,6 +377,73 @@ This means that primitives such as booleans, integers, etc. will be represented
.. versionadded:: 3.2.0
**AUDITLOG_USE_BASE_MANAGER**
This configuration variable determines whether to use `base managers
<https://docs.djangoproject.com/en/dev/topics/db/managers/#base-managers>`_ for
tracked models instead of their default managers.
This setting can be useful for applications where the default manager behaviour
hides some objects from the majority of ORM queries:
.. code-block:: python
class SecretManager(models.Manager):
def get_queryset(self):
return super().get_queryset().filter(is_secret=False)
@auditlog.register()
class SwappedManagerModel(models.Model):
is_secret = models.BooleanField(default=False)
name = models.CharField(max_length=255)
objects = SecretManager()
In this example, when ``AUDITLOG_USE_BASE_MANAGER`` is set to `True`, objects
with the `is_secret` field set will be made visible to Auditlog. Otherwise you
may see inaccurate data in log entries, recording changes to a seemingly
"non-existent" object with empty fields.
.. versionadded:: 3.4.0
**AUDITLOG_LOGENTRY_MODEL**
This configuration variable allows you to specify a custom model to be used instead of the default
:py:class:`auditlog.models.LogEntry` model for storing audit records.
By default, Auditlog stores change records in the built-in ``LogEntry`` model.
If you need to store additional information in each log entry (for example, a user role, request metadata,
or any other contextual data), you can define your own model by subclassing
:py:class:`auditlog.models.AbstractLogEntry` and configure it using this setting.
.. code-block:: python
from django.db import models
from auditlog.models import AbstractLogEntry
class CustomLogEntryModel(AbstractLogEntry):
role = models.CharField(max_length=100, null=True, blank=True)
Then, in your project settings:
.. code-block:: python
AUDITLOG_LOGENTRY_MODEL = 'custom_log_app.CustomLogEntryModel'
Once defined, Auditlog will automatically use the specified model for all future log entries instead
of the default one.
.. note::
- The custom model **must** inherit from :py:class:`auditlog.models.AbstractLogEntry`.
- All fields and behaviors defined in :py:class:`AbstractLogEntry` should remain intact to ensure compatibility.
- The app label and model name in ``AUDITLOG_LOGENTRY_MODEL`` must follow Djangos standard dotted notation
(for example, ``"app_name.ModelName"``).
.. versionadded:: 3.5.0
Custom LogEntry model configuration via ``AUDITLOG_LOGENTRY_MODEL``
Actors
------

View file

@ -29,12 +29,11 @@ setup(
description="Audit log app for Django",
long_description=long_description,
long_description_content_type="text/markdown",
python_requires=">=3.9",
python_requires=">=3.10",
install_requires=["Django>=4.2", "python-dateutil>=2.7.0"],
zip_safe=False,
classifiers=[
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",

17
tox.ini
View file

@ -1,13 +1,14 @@
[tox]
envlist =
{py39,py310,py311}-{customlogmodel,defaultlogmodel}-django42
{py310,py311}-{customlogmodel,defaultlogmodel}-django42
{py310,py311,py312}-{customlogmodel,defaultlogmodel}-django50
{py310,py311,py312,py313}-{customlogmodel,defaultlogmodel}-django51
{py310,py311,py312,py313}-{customlogmodel,defaultlogmodel}-django52
{py312,py313}-{customlogmodel,defaultlogmodel}-djangomain
py39-docs
py39-lint
py39-checkmigrations
py310-docs
py310-lint
py310-checkmigrations
[testenv]
setenv =
@ -43,19 +44,18 @@ basepython =
py312: python3.12
py311: python3.11
py310: python3.10
py39: python3.9
[testenv:py39-docs]
[testenv:py310-docs]
changedir = docs/source
deps = -rdocs/requirements.txt
commands = sphinx-build -W -b html -d {envtmpdir}/doctrees . {envtmpdir}/html
[testenv:py39-lint]
[testenv:py310-lint]
deps = pre-commit
commands =
pre-commit run --all-files
[testenv:py39-checkmigrations]
[testenv:py310-checkmigrations]
description = Check for missing migrations
changedir = auditlog_tests
deps =
@ -74,7 +74,6 @@ commands =
[gh-actions]
python =
3.9: py39
3.10: py310
3.11: py311
3.12: py312