Compare commits

..

23 commits

Author SHA1 Message Date
pre-commit-ci[bot]
0e3a2ec1a7
[pre-commit.ci] pre-commit autoupdate (#808)
Some checks failed
Test / SQLite • Python 3.10 (push) Has been cancelled
Test / SQLite • Python 3.11 (push) Has been cancelled
Test / SQLite • Python 3.12 (push) Has been cancelled
Test / SQLite • Python 3.13 (push) Has been cancelled
Test / PostgreSQL • Python 3.10 (push) Has been cancelled
Test / PostgreSQL • Python 3.11 (push) Has been cancelled
Test / PostgreSQL • Python 3.12 (push) Has been cancelled
Test / PostgreSQL • Python 3.13 (push) Has been cancelled
Test / MySQL • Python 3.10 (push) Has been cancelled
Test / MySQL • Python 3.11 (push) Has been cancelled
Test / MySQL • Python 3.12 (push) Has been cancelled
Test / MySQL • Python 3.13 (push) Has been cancelled
updates:
- [github.com/psf/black-pre-commit-mirror: 26.1.0 → 26.3.0](https://github.com/psf/black-pre-commit-mirror/compare/26.1.0...26.3.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-03-10 10:58:29 +01:00
pre-commit-ci[bot]
dfd5b79d2d
[pre-commit.ci] pre-commit autoupdate (#807)
Some checks failed
Test / SQLite • Python 3.10 (push) Has been cancelled
Test / SQLite • Python 3.11 (push) Has been cancelled
Test / SQLite • Python 3.12 (push) Has been cancelled
Test / SQLite • Python 3.13 (push) Has been cancelled
Test / PostgreSQL • Python 3.10 (push) Has been cancelled
Test / PostgreSQL • Python 3.11 (push) Has been cancelled
Test / PostgreSQL • Python 3.12 (push) Has been cancelled
Test / PostgreSQL • Python 3.13 (push) Has been cancelled
Test / MySQL • Python 3.10 (push) Has been cancelled
Test / MySQL • Python 3.11 (push) Has been cancelled
Test / MySQL • Python 3.12 (push) Has been cancelled
Test / MySQL • Python 3.13 (push) Has been cancelled
updates:
- [github.com/PyCQA/isort: 8.0.0 → 8.0.1](https://github.com/PyCQA/isort/compare/8.0.0...8.0.1)
- [github.com/adamchainz/django-upgrade: 1.29.1 → 1.30.0](https://github.com/adamchainz/django-upgrade/compare/1.29.1...1.30.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-03-04 13:06:54 +01:00
pre-commit-ci[bot]
4154560de3
[pre-commit.ci] pre-commit autoupdate (#806)
Some checks failed
Test / SQLite • Python 3.10 (push) Has been cancelled
Test / SQLite • Python 3.11 (push) Has been cancelled
Test / SQLite • Python 3.12 (push) Has been cancelled
Test / SQLite • Python 3.13 (push) Has been cancelled
Test / PostgreSQL • Python 3.10 (push) Has been cancelled
Test / PostgreSQL • Python 3.11 (push) Has been cancelled
Test / PostgreSQL • Python 3.12 (push) Has been cancelled
Test / PostgreSQL • Python 3.13 (push) Has been cancelled
Test / MySQL • Python 3.10 (push) Has been cancelled
Test / MySQL • Python 3.11 (push) Has been cancelled
Test / MySQL • Python 3.12 (push) Has been cancelled
Test / MySQL • Python 3.13 (push) Has been cancelled
updates:
- [github.com/PyCQA/isort: 7.0.0 → 8.0.0](https://github.com/PyCQA/isort/compare/7.0.0...8.0.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-02-24 11:56:32 +00:00
congusbongus
3f255a02d9
Fix typo in usage.rst regarding field exclusion (#801)
Some checks failed
Test / SQLite • Python 3.10 (push) Has been cancelled
Test / SQLite • Python 3.11 (push) Has been cancelled
Test / SQLite • Python 3.12 (push) Has been cancelled
Test / SQLite • Python 3.13 (push) Has been cancelled
Test / PostgreSQL • Python 3.10 (push) Has been cancelled
Test / PostgreSQL • Python 3.11 (push) Has been cancelled
Test / PostgreSQL • Python 3.12 (push) Has been cancelled
Test / PostgreSQL • Python 3.13 (push) Has been cancelled
Test / MySQL • Python 3.10 (push) Has been cancelled
Test / MySQL • Python 3.11 (push) Has been cancelled
Test / MySQL • Python 3.12 (push) Has been cancelled
Test / MySQL • Python 3.13 (push) Has been cancelled
2026-02-05 10:56:57 +01:00
marco-thirona
6d170da5fc
Add support for m2m changes in AbstractLogEntry.changes_str (#798)
Some checks failed
Test / SQLite • Python 3.10 (push) Has been cancelled
Test / SQLite • Python 3.11 (push) Has been cancelled
Test / SQLite • Python 3.12 (push) Has been cancelled
Test / SQLite • Python 3.13 (push) Has been cancelled
Test / PostgreSQL • Python 3.10 (push) Has been cancelled
Test / PostgreSQL • Python 3.11 (push) Has been cancelled
Test / PostgreSQL • Python 3.12 (push) Has been cancelled
Test / PostgreSQL • Python 3.13 (push) Has been cancelled
Test / MySQL • Python 3.10 (push) Has been cancelled
Test / MySQL • Python 3.11 (push) Has been cancelled
Test / MySQL • Python 3.12 (push) Has been cancelled
Test / MySQL • Python 3.13 (push) Has been cancelled
* Add test case to test log entry changes_str property for m2m changes.

* Add support for m2m field changes and generic changes in AbstractLogEntry.changes_str property.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Add more test cases for changes_str.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Add chengelog note

* Validate type and length of changes_dict values.

* Restructure change iterator.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-01-29 21:44:40 +01:00
pre-commit-ci[bot]
198c060c3b
[pre-commit.ci] pre-commit autoupdate (#799)
Some checks failed
Test / SQLite • Python 3.10 (push) Has been cancelled
Test / SQLite • Python 3.11 (push) Has been cancelled
Test / SQLite • Python 3.12 (push) Has been cancelled
Test / SQLite • Python 3.13 (push) Has been cancelled
Test / PostgreSQL • Python 3.10 (push) Has been cancelled
Test / PostgreSQL • Python 3.11 (push) Has been cancelled
Test / PostgreSQL • Python 3.12 (push) Has been cancelled
Test / PostgreSQL • Python 3.13 (push) Has been cancelled
Test / MySQL • Python 3.10 (push) Has been cancelled
Test / MySQL • Python 3.11 (push) Has been cancelled
Test / MySQL • Python 3.12 (push) Has been cancelled
Test / MySQL • Python 3.13 (push) Has been cancelled
* [pre-commit.ci] pre-commit autoupdate

updates:
- [github.com/psf/black-pre-commit-mirror: 25.12.0 → 26.1.0](https://github.com/psf/black-pre-commit-mirror/compare/25.12.0...26.1.0)

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-01-19 19:43:03 +01:00
pre-commit-ci[bot]
ede4d10164
[pre-commit.ci] pre-commit autoupdate (#790)
updates:
- [github.com/psf/black-pre-commit-mirror: 25.11.0 → 25.12.0](https://github.com/psf/black-pre-commit-mirror/compare/25.11.0...25.12.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-01-07 19:57:08 +01:00
Youngkwang Yang
aedb6ead39
Update CHANGELOG.md (#791) 2025-12-18 17:52:50 +09:00
dependabot[bot]
fb762a054f
Bump actions/cache from 4 to 5 in the github-actions group (#794)
Bumps the github-actions group with 1 update: [actions/cache](https://github.com/actions/cache).


Updates `actions/cache` from 4 to 5
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/cache
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-16 08:48:43 +01:00
James Gillard
dc636716b0
Fix AttributeError when AUDITLOG_LOGENTRY_MODEL is not set (#788) (#789)
Use getattr() with default value in get_logentry_model() to handle
cases where conf.py hasn't been imported yet due to import order.

Adds regression test to verify the fix.
2025-12-13 00:28:10 +09:00
Youngkwang Yang
66125030a8
Update CHANGELOG.md (#787) 2025-12-05 00:13:49 +09:00
Fabian Allendorf
eb9eefd76f
Add new setting to control FK change representation (#779)
* add new setting to activate string repr

* skip using foreign key to construct and display diff of foreign key fields

* concise name and documentation update

# Conflicts:
#	docs/source/usage.rst

* add test for enabled setting

* fix code block in docs

* fix version

* add warning to documentation
2025-12-02 16:49:16 +01:00
Lukas Graf
d02ed6b9e0
Make diffing more robust for polymorphic models (#784)
* Add failing test for diffing polymorphic model instances.

* Make diffing more robust for polymorphic models:

When working with polymorphic models, where a child model inherits from a
parent model, Django's pre_save signal may send model instances in a way
where the log_update() handler receives an instance of the child as the
`old` model, but an instance of the parent as the `new` model.

This leads to a `FieldDoesNotExist` error when a field that only exists on the
child was modified, and `get_field_value()` attempts look up that field on the
parent.

This change makes diffing polymorphic models more robust by considering this
case in `get_default_value()`. Changes to those child fields won't be tracked
in these cases, but at least `django-auditlog` won't prevent the model from
being saved.
2025-11-25 09:12:41 +01:00
pre-commit-ci[bot]
074e6aa145
[pre-commit.ci] pre-commit autoupdate (#782)
updates:
- [github.com/psf/black-pre-commit-mirror: 25.9.0 → 25.11.0](https://github.com/psf/black-pre-commit-mirror/compare/25.9.0...25.11.0)
- [github.com/asottile/pyupgrade: v3.21.0 → v3.21.2](https://github.com/asottile/pyupgrade/compare/v3.21.0...v3.21.2)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-11-24 19:19:51 +01:00
dependabot[bot]
03336f9ef2
Bump actions/checkout from 5 to 6 in the github-actions group (#783)
Bumps the github-actions group with 1 update: [actions/checkout](https://github.com/actions/checkout).


Updates `actions/checkout` from 5 to 6
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-24 13:56:49 +01:00
mostafaeftekharizadeh
7d13fd4ba8
Add CustomLogEntry model support and update tests: (#764)
* Add CustomLogEntry model support and update tests:

- Added support for CustomLogEntry data model to extend django-auditlog capabilities

- Updated existing test cases to align with new model structure and data handling logic

- Added new test cases to validate CustomLogEntry behavior, model registration, and signal handling

- Ensured backward compatibility with existing LogEntry model where applicable

* Update auditlog/__init__.py

Co-authored-by: Youngkwang Yang <me@youngkwang.dev>

* run only one custom model test matrix (#761)

---------

Co-authored-by: Youngkwang Yang <me@youngkwang.dev>
2025-11-19 09:46:43 +01:00
Hasan Ramezani
0e58a9d2d5
Update mysql version in test docker-compose (#777) 2025-10-31 14:53:16 +01:00
pre-commit-ci[bot]
8c9b7b4a6e
[pre-commit.ci] pre-commit autoupdate (#774)
updates:
- [github.com/PyCQA/isort: 6.1.0 → 7.0.0](https://github.com/PyCQA/isort/compare/6.1.0...7.0.0)
- [github.com/asottile/pyupgrade: v3.20.0 → v3.21.0](https://github.com/asottile/pyupgrade/compare/v3.20.0...v3.21.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-10-21 09:26:37 +02:00
David Thompson
b1b6f9f4dd
Add base manager setting to override default manager use (#747) (#766)
* Add `AUDITLOG_USE_BASE_MANAGER` setting configuration
* Adjust `LogEntry._get_changes_display_for_fk_field` behaviour for setting
* Adjust `log_update` and `log_m2m_changes` in `receivers.py` for setting
* Add `ModelManagerTest.test_use_base_manager_setting`
* Add entry in Usage documentation
* (In passing, fix a formatting issue in `usage.rst`)

The `AUDITLOG_USE_BASE_MANAGER` setting has a default of `False` to maintain
initial backwards compatibility with previous versions.
2025-10-18 13:55:43 +02:00
Youngkwang Yang
d417f30142
Drop 'Python 3.9' support (#773)
* Drop Python 3.9 support, set minimum version to 3.10

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Update CHANGELOG.md

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Fix lint error

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-10-17 17:51:53 +02:00
Dan Bentley
bd03eb6199
Amend setup configuration to include non-python package files (#769)
* Amend setup configuration to include non-python package files

Fixes: #767

Adds locale files created in: #762

* Amend render tests to use templatetag

Tests now use the public interface to render function.

Addresses regression in test coverage caused by adding templatetag to wheel.
2025-10-11 09:57:22 +02:00
Matt Cengia
74ba152a67
Fix copy-paste error in documentation heading (#760) 2025-10-10 10:57:42 +02:00
pre-commit-ci[bot]
85056b73c3
[pre-commit.ci] pre-commit autoupdate (#765)
updates:
- https://github.com/psf/blackhttps://github.com/psf/black-pre-commit-mirror
- [github.com/psf/black-pre-commit-mirror: 25.1.0 → 25.9.0](https://github.com/psf/black-pre-commit-mirror/compare/25.1.0...25.9.0)
- [github.com/PyCQA/isort: 6.0.1 → 6.1.0](https://github.com/PyCQA/isort/compare/6.0.1...6.1.0)
- [github.com/adamchainz/django-upgrade: 1.27.0 → 1.29.0](https://github.com/adamchainz/django-upgrade/compare/1.27.0...1.29.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-10-07 09:52:18 +02:00
39 changed files with 918 additions and 148 deletions

View file

@ -11,14 +11,14 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
with:
fetch-depth: 0
- 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
@ -26,7 +26,7 @@ jobs:
echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT
- name: Cache
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ${{ steps.pip-cache.outputs.dir }}
key: release-${{ hashFiles('**/setup.py') }}

View file

@ -9,9 +9,9 @@ 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
- uses: actions/checkout@v6
- name: Setup Python and dependencies
uses: ./.github/actions/setup-python-deps
@ -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
@ -51,7 +51,7 @@ jobs:
--health-timeout 5s
--health-retries 10
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Setup Python and dependencies
uses: ./.github/actions/setup-python-deps
@ -81,10 +81,10 @@ 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
image: mysql:8.4
env:
MYSQL_DATABASE: auditlog
MYSQL_USER: mysql
@ -98,7 +98,7 @@ jobs:
--health-timeout=5s
--health-retries=20
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Install MySQL client libraries
run: |

View file

@ -1,29 +1,29 @@
---
repos:
- repo: https://github.com/psf/black
rev: 25.1.0
- repo: https://github.com/psf/black-pre-commit-mirror
rev: 26.3.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.0.1
rev: 8.0.1
hooks:
- id: isort
- repo: https://github.com/asottile/pyupgrade
rev: v3.20.0
rev: v3.21.2
hooks:
- id: pyupgrade
args: [--py39-plus]
args: [--py310-plus]
- repo: https://github.com/adamchainz/django-upgrade
rev: 1.27.0
rev: 1.30.0
hooks:
- id: django-upgrade
args: [--target-version, "4.2"]

View file

@ -2,6 +2,30 @@
## Next Release
#### Fixes
- `KeyError` when calling `changes_str` on a log entry that tracks many-to-many field changes ([#798](https://github.com/jazzband/django-auditlog/pull/798))
## 3.4.1 (2025-12-13)
#### Fixes
- Fix AttributeError when AUDITLOG_LOGENTRY_MODEL is not set ([#789](https://github.com/jazzband/django-auditlog/pull/789))
## 3.4.0 (2025-12-04)
#### Improvements
- feat: Add CustomLogEntry model support and update tests ([#764)](https://github.com/jazzband/django-auditlog/pull/764))
- feat: Add `AUDITLOG_USE_FK_STRING_REPRESENTATION` setting that controls how foreign key changes are represented ([#779)](https://github.com/jazzband/django-auditlog/pull/779))
- 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))
#### Fixes
- Make diffing more robust for polymorphic models ([#784](https://github.com/jazzband/django-auditlog/pull/784))
- Amend setup configuration to include non-python package files ([#769](https://github.com/jazzband/django-auditlog/pull/769))
## 3.3.0 (2025-09-18)
#### Improvements

3
MANIFEST.in Normal file
View file

@ -0,0 +1,3 @@
recursive-include auditlog/templates *
recursive-include auditlog/static *
recursive-include auditlog/locale *

View file

@ -1,3 +1,24 @@
from __future__ import annotations
from importlib.metadata import version
from django.apps import apps as django_apps
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
__version__ = version("django-auditlog")
def get_logentry_model():
model_string = getattr(settings, "AUDITLOG_LOGENTRY_MODEL", "auditlog.LogEntry")
try:
return django_apps.get_model(model_string, require_ready=False)
except ValueError:
raise ImproperlyConfigured(
"AUDITLOG_LOGENTRY_MODEL must be of the form 'app_label.model_name'"
)
except LookupError:
raise ImproperlyConfigured(
"AUDITLOG_LOGENTRY_MODEL refers to model '%s' that has not been installed"
% model_string
)

View file

@ -4,9 +4,11 @@ from django.contrib import admin
from django.contrib.auth import get_user_model
from django.utils.translation import gettext_lazy as _
from auditlog import get_logentry_model
from auditlog.filters import CIDFilter, ResourceTypeFilter
from auditlog.mixins import LogEntryAdminMixin
from auditlog.models import LogEntry
LogEntry = get_logentry_model()
@admin.register(LogEntry)

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

@ -62,3 +62,17 @@ settings.AUDITLOG_STORE_JSON_CHANGES = getattr(
)
settings.AUDITLOG_MASK_CALLABLE = getattr(settings, "AUDITLOG_MASK_CALLABLE", None)
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
)
# Use string representation of referenced object in foreign key changes instead of its primary key
settings.AUDITLOG_USE_FK_STRING_REPRESENTATION = getattr(
settings, "AUDITLOG_USE_FK_STRING_REPRESENTATION", False
)

View file

@ -6,7 +6,7 @@ from functools import partial
from django.contrib.auth import get_user_model
from django.db.models.signals import pre_save
from auditlog.models import LogEntry
from auditlog import get_logentry_model
auditlog_value = ContextVar("auditlog_value")
auditlog_disabled = ContextVar("auditlog_disabled", default=False)
@ -14,23 +14,33 @@ auditlog_disabled = ContextVar("auditlog_disabled", default=False)
@contextlib.contextmanager
def set_actor(actor, remote_addr=None, remote_port=None):
"""Connect a signal receiver with current user attached."""
# Initialize thread local storage
context_data = {
"signal_duid": ("set_actor", time.time()),
"actor": actor,
"remote_addr": remote_addr,
"remote_port": remote_port,
}
return call_context_manager(context_data)
@contextlib.contextmanager
def set_extra_data(context_data):
return call_context_manager(context_data)
def call_context_manager(context_data):
"""Connect a signal receiver with current user attached."""
LogEntry = get_logentry_model()
# Initialize thread local storage
context_data["signal_duid"] = ("set_actor", time.time())
auditlog_value.set(context_data)
# Connect signal for automatic logging
set_actor = partial(
_set_actor,
user=actor,
set_extra_data = partial(
_set_extra_data,
signal_duid=context_data["signal_duid"],
)
pre_save.connect(
set_actor,
set_extra_data,
sender=LogEntry,
dispatch_uid=context_data["signal_duid"],
weak=False,
@ -47,11 +57,26 @@ def set_actor(actor, remote_addr=None, remote_port=None):
pre_save.disconnect(sender=LogEntry, dispatch_uid=auditlog["signal_duid"])
def _set_actor(user, sender, instance, signal_duid, **kwargs):
def _set_actor(auditlog, instance, sender):
LogEntry = get_logentry_model()
auth_user_model = get_user_model()
if "actor" in auditlog:
actor = auditlog.get("actor")
if (
sender == LogEntry
and isinstance(actor, auth_user_model)
and instance.actor is None
):
instance.actor = actor
instance.actor_email = getattr(actor, "email", None)
def _set_extra_data(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.
"""
LogEntry = get_logentry_model()
try:
auditlog = auditlog_value.get()
except LookupError:
@ -59,17 +84,15 @@ def _set_actor(user, sender, instance, signal_duid, **kwargs):
else:
if signal_duid != auditlog["signal_duid"]:
return
auth_user_model = get_user_model()
if (
sender == LogEntry
and isinstance(user, auth_user_model)
and instance.actor is None
):
instance.actor = user
instance.actor_email = getattr(user, "email", None)
instance.remote_addr = auditlog["remote_addr"]
instance.remote_port = auditlog["remote_port"]
_set_actor(auditlog, instance, sender)
for key in auditlog:
if key != "actor" and hasattr(LogEntry, key):
if callable(auditlog[key]):
setattr(instance, key, auditlog[key]())
else:
setattr(instance, key, auditlog[key])
@contextlib.contextmanager

View file

@ -1,14 +1,16 @@
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
from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist
from django.db.models import NOT_PROVIDED, DateTimeField, ForeignKey, JSONField, Model
from django.utils import timezone as django_timezone
from django.utils.encoding import smart_str
from django.utils.module_loading import import_string
from auditlog import get_logentry_model
def track_field(field):
"""
@ -21,7 +23,6 @@ def track_field(field):
:return: Whether the given field should be tracked.
:rtype: bool
"""
from auditlog.models import LogEntry
# Do not track many to many relations
if field.many_to_many:
@ -30,7 +31,7 @@ def track_field(field):
# Do not track relations to LogEntry
if (
getattr(field, "remote_field", None) is not None
and field.remote_field.model == LogEntry
and field.remote_field.model == get_logentry_model()
):
return False
@ -73,7 +74,7 @@ def get_field_value(obj, field, use_json_for_changes=False):
try:
model_field = obj._meta.get_field(field.name)
default = model_field.default
except AttributeError:
except (AttributeError, FieldDoesNotExist):
default = NOT_PROVIDED
if default is NOT_PROVIDED:
@ -105,7 +106,11 @@ def get_field_value(obj, field, use_json_for_changes=False):
value = json.dumps(value, sort_keys=True, cls=field.encoder)
except TypeError:
pass
elif (field.one_to_one or field.many_to_one) and hasattr(field, "rel_class"):
elif (
not settings.AUDITLOG_USE_FK_STRING_REPRESENTATION
and (field.one_to_one or field.many_to_one)
and hasattr(field, "rel_class")
):
value = smart_str(getattr(obj, field.get_attname()), strings_only=True)
else:
value = getattr(obj, field.name)
@ -131,7 +136,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
@ -168,8 +173,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

@ -3,7 +3,9 @@ import datetime
from django.core.management.base import BaseCommand
from django.db import connection
from auditlog.models import LogEntry
from auditlog import get_logentry_model
LogEntry = get_logentry_model()
class Command(BaseCommand):

View file

@ -4,7 +4,9 @@ from django.conf import settings
from django.core.management import CommandError, CommandParser
from django.core.management.base import BaseCommand
from auditlog.models import LogEntry
from auditlog import get_logentry_model
LogEntry = get_logentry_model()
class Command(BaseCommand):
@ -124,15 +126,13 @@ class Command(BaseCommand):
def postgres():
with connection.cursor() as cursor:
cursor.execute(
"""
UPDATE auditlog_logentry
cursor.execute(f"""
UPDATE {LogEntry._meta.db_table}
SET changes="changes_text"::jsonb
WHERE changes_text IS NOT NULL
AND changes_text <> ''
AND changes IS NULL
"""
)
""")
return cursor.cursor.rowcount
if database == "postgres":

View file

@ -1,10 +1,8 @@
from typing import Optional
from django.conf import settings
from django.contrib.auth import get_user_model
from auditlog.cid import set_cid
from auditlog.context import set_actor
from auditlog.context import set_extra_data
class AuditlogMiddleware:
@ -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:
@ -56,12 +54,17 @@ class AuditlogMiddleware:
return user
return None
def __call__(self, request):
remote_addr = self._get_remote_addr(request)
remote_port = self._get_remote_port(request)
user = self._get_actor(request)
def get_extra_data(self, request):
context_data = {}
context_data["remote_addr"] = self._get_remote_addr(request)
context_data["remote_port"] = self._get_remote_port(request)
context_data["actor"] = self._get_actor(request)
return context_data
def __call__(self, request):
set_cid(request)
with set_actor(actor=user, remote_addr=remote_addr, remote_port=remote_port):
with set_extra_data(context_data=self.get_extra_data(request)):
return self.get_response(request)

View file

@ -14,10 +14,12 @@ from django.utils.text import capfirst
from django.utils.timezone import is_aware, localtime
from django.utils.translation import gettext_lazy as _
from auditlog.models import LogEntry
from auditlog import get_logentry_model
from auditlog.render import get_field_verbose_name, render_logentry_changes_html
from auditlog.signals import accessed
LogEntry = get_logentry_model()
MAX = 75

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
@ -23,6 +24,7 @@ from django.utils import timezone as django_timezone
from django.utils.encoding import smart_str
from django.utils.translation import gettext_lazy as _
from auditlog import get_logentry_model
from auditlog.diff import get_mask_function
DEFAULT_OBJECT_REPR = "<error forming object repr>"
@ -303,7 +305,7 @@ class LogEntryManager(models.Manager):
return data
class LogEntry(models.Model):
class AbstractLogEntry(models.Model):
"""
Represents an entry in the audit log. The content type is saved along with the textual and numeric
(if available) primary key, as well as the textual representation of the object when it was saved.
@ -392,6 +394,7 @@ class LogEntry(models.Model):
objects = LogEntryManager()
class Meta:
abstract = True
get_latest_by = "timestamp"
ordering = ["-timestamp"]
verbose_name = _("log entry")
@ -424,21 +427,29 @@ class LogEntry(models.Model):
not satisfying, please use :py:func:`LogEntry.changes_dict` and format the string yourself.
:param colon: The string to place between the field name and the values.
:param arrow: The string to place between each old and new value.
:param arrow: The string to place between each old and new value (non-m2m field changes only).
:param separator: The string to place between each field.
:return: A readable string of the changes in this log entry.
"""
substrings = []
for field, values in self.changes_dict.items():
substring = "{field_name:s}{colon:s}{old:s}{arrow:s}{new:s}".format(
field_name=field,
colon=colon,
old=values[0],
arrow=arrow,
new=values[1],
)
substrings.append(substring)
for field, value in sorted(self.changes_dict.items()):
if isinstance(value, (list, tuple)) and len(value) == 2:
# handle regular field change
substring = "{field_name:s}{colon:s}{old:s}{arrow:s}{new:s}".format(
field_name=field,
colon=colon,
old=value[0],
arrow=arrow,
new=value[1],
)
substrings.append(substring)
elif isinstance(value, dict) and value.get("type") == "m2m":
# handle m2m change
substring = (
f"{field}{colon}{value['operation']} {sorted(value['objects'])}"
)
substrings.append(substring)
return separator.join(substrings)
@ -534,7 +545,7 @@ class LogEntry(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
@ -553,12 +564,19 @@ class LogEntry(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})"
class LogEntry(AbstractLogEntry):
class Meta(AbstractLogEntry.Meta):
swappable = "AUDITLOG_LOGENTRY_MODEL"
class AuditlogHistoryField(GenericRelation):
"""
A subclass of py:class:`django.contrib.contenttypes.fields.GenericRelation` that sets some default
@ -579,7 +597,7 @@ class AuditlogHistoryField(GenericRelation):
"""
def __init__(self, pk_indexable=True, delete_related=False, **kwargs):
kwargs["to"] = LogEntry
kwargs["to"] = get_logentry_model()
if pk_indexable:
kwargs["object_id_field"] = "object_id"
@ -622,3 +640,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

@ -2,9 +2,10 @@ from functools import wraps
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 LogEntry
from auditlog.models import _get_manager_from_settings
from auditlog.signals import post_log, pre_log
@ -38,7 +39,7 @@ def log_create(sender, instance, created, **kwargs):
"""
if created:
_create_log_entry(
action=LogEntry.Action.CREATE,
action=get_logentry_model().Action.CREATE,
instance=instance,
sender=sender,
diff_old=None,
@ -56,9 +57,9 @@ 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=LogEntry.Action.UPDATE,
action=get_logentry_model().Action.UPDATE,
instance=instance,
sender=sender,
diff_old=old,
@ -77,7 +78,7 @@ def log_delete(sender, instance, **kwargs):
"""
if instance.pk is not None:
_create_log_entry(
action=LogEntry.Action.DELETE,
action=get_logentry_model().Action.DELETE,
instance=instance,
sender=sender,
diff_old=instance,
@ -94,7 +95,7 @@ def log_access(sender, instance, **kwargs):
"""
if instance.pk is not None:
_create_log_entry(
action=LogEntry.Action.ACCESS,
action=get_logentry_model().Action.ACCESS,
instance=instance,
sender=sender,
diff_old=None,
@ -122,6 +123,7 @@ def _create_log_entry(
if any(item[1] is False for item in pre_log_results):
return
LogEntry = get_logentry_model()
error = None
log_entry = None
@ -169,13 +171,14 @@ def make_log_m2m_changes(field_name):
"""Handle m2m_changed and call LogEntry.objects.log_m2m_changes as needed."""
if action not in ["post_add", "post_clear", "post_remove"]:
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
@ -29,7 +29,7 @@ class AuditlogModelRegistry:
A registry that keeps track of the models that use Auditlog to track changes.
"""
DEFAULT_EXCLUDE_MODELS = ("auditlog.LogEntry", "admin.LogEntry")
DEFAULT_EXCLUDE_MODELS = (settings.AUDITLOG_LOGENTRY_MODEL, "admin.LogEntry")
def __init__(
self,
@ -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

@ -0,0 +1,5 @@
from django.apps import AppConfig
class CustomLogEntryConfig(AppConfig):
name = "custom_logentry_app"

View file

@ -0,0 +1,138 @@
# Generated by Django 4.2.25 on 2025-10-14 04:17
import django.db.models.deletion
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("contenttypes", "0002_remove_content_type_name"),
]
operations = [
migrations.CreateModel(
name="CustomLogEntryModel",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"object_pk",
models.CharField(
db_index=True, max_length=255, verbose_name="object pk"
),
),
(
"object_id",
models.BigIntegerField(
blank=True, db_index=True, null=True, verbose_name="object id"
),
),
("object_repr", models.TextField(verbose_name="object representation")),
("serialized_data", models.JSONField(null=True)),
(
"action",
models.PositiveSmallIntegerField(
choices=[
(0, "create"),
(1, "update"),
(2, "delete"),
(3, "access"),
],
db_index=True,
verbose_name="action",
),
),
(
"changes_text",
models.TextField(blank=True, verbose_name="change message"),
),
("changes", models.JSONField(null=True, verbose_name="change message")),
(
"cid",
models.CharField(
blank=True,
db_index=True,
max_length=255,
null=True,
verbose_name="Correlation ID",
),
),
(
"remote_addr",
models.GenericIPAddressField(
blank=True, null=True, verbose_name="remote address"
),
),
(
"remote_port",
models.PositiveIntegerField(
blank=True, null=True, verbose_name="remote port"
),
),
(
"timestamp",
models.DateTimeField(
db_index=True,
default=django.utils.timezone.now,
verbose_name="timestamp",
),
),
(
"additional_data",
models.JSONField(
blank=True, null=True, verbose_name="additional data"
),
),
(
"actor_email",
models.CharField(
blank=True,
max_length=254,
null=True,
verbose_name="actor email",
),
),
("role", models.CharField(blank=True, max_length=100, null=True)),
(
"actor",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="+",
to=settings.AUTH_USER_MODEL,
verbose_name="actor",
),
),
(
"content_type",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="+",
to="contenttypes.contenttype",
verbose_name="content type",
),
),
],
options={
"verbose_name": "log entry",
"verbose_name_plural": "log entries",
"ordering": ["-timestamp"],
"get_latest_by": "timestamp",
"abstract": False,
},
),
]

View file

@ -0,0 +1,7 @@
from django.db import models
from auditlog.models import AbstractLogEntry
class CustomLogEntryModel(AbstractLogEntry):
role = models.CharField(max_length=100, null=True, blank=True)

View file

@ -20,7 +20,7 @@ services:
mysql:
container_name: auditlog_mysql
platform: linux/x86_64
image: mysql:8.0
image: mysql:8.4
restart: "no"
environment:
MYSQL_DATABASE: auditlog

View file

@ -0,0 +1,12 @@
from auditlog.middleware import AuditlogMiddleware
class CustomAuditlogMiddleware(AuditlogMiddleware):
"""
Custom Middleware to couple the request's user role to log items.
"""
def get_extra_data(self, request):
context_data = super().get_extra_data(request)
context_data["role"] = "Role 1"
return context_data

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)
@ -453,12 +487,15 @@ auditlog.register(AltPrimaryKeyModel)
auditlog.register(UUIDPrimaryKeyModel)
auditlog.register(ModelPrimaryKeyModel)
auditlog.register(ProxyModel)
auditlog.register(RelatedModelParent)
auditlog.register(RelatedModel)
auditlog.register(ManyRelatedModel)
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

@ -121,6 +121,9 @@ class AuditlogFlushWithTruncateTest(TransactionTestCase):
self.mock_input = input_patcher.start()
self.addCleanup(input_patcher.stop)
def _fixture_teardown(self):
call_command("flush", verbosity=0, interactive=False, allow_cascade=True)
def make_object(self):
return SimpleModel.objects.create(text="I am a simple model.")

View file

@ -1,8 +1,10 @@
from django.test import TestCase
from test_app.models import SimpleModel
from auditlog.models import LogEntry
from auditlog.render import render_logentry_changes_html
from auditlog import get_logentry_model
from auditlog.templatetags.auditlog_tags import render_logentry_changes_html
LogEntry = get_logentry_model()
class RenderChangesTest(TestCase):

View file

@ -18,6 +18,7 @@ INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.staticfiles",
"django.contrib.postgres",
"custom_logentry_app",
"auditlog",
"test_app",
]
@ -28,9 +29,14 @@ MIDDLEWARE = [
"django.contrib.sessions.middleware.SessionMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"auditlog.middleware.AuditlogMiddleware",
]
if os.environ.get("AUDITLOG_LOGENTRY_MODEL", None):
MIDDLEWARE = MIDDLEWARE + ["auditlog.middleware.AuditlogMiddleware"]
else:
MIDDLEWARE = MIDDLEWARE + ["middleware.CustomAuditlogMiddleware"]
if TEST_DB_BACKEND == "postgresql":
DATABASES = {
"default": {
@ -100,3 +106,5 @@ ROOT_URLCONF = "test_app.urls"
USE_TZ = True
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
AUDITLOG_LOGENTRY_MODEL = os.environ.get("AUDITLOG_LOGENTRY_MODEL", "auditlog.LogEntry")

View file

@ -8,7 +8,9 @@ from django.test import TestCase, override_settings
from django.test.utils import skipIf
from test_app.models import SimpleModel
from auditlog.models import LogEntry
from auditlog import get_logentry_model
LogEntry = get_logentry_model()
class TwoStepMigrationTest(TestCase):
@ -119,7 +121,10 @@ class AuditlogMigrateJsonTest(TestCase):
self.make_logentry()
# Act
with patch("auditlog.models.LogEntry.objects.bulk_update") as bulk_update:
LogEntry = get_logentry_model()
path = f"{LogEntry.__module__}.{LogEntry.__name__}.objects.bulk_update"
with patch(path) as bulk_update:
outbuf, errbuf = self.call_command("-b=1")
call_count = bulk_update.call_count

View file

@ -1,9 +1,11 @@
from django.test import TestCase, override_settings
from test_app.models import JSONModel, NullableFieldModel, RelatedModel, SimpleModel
from auditlog.models import LogEntry
from auditlog import get_logentry_model
from auditlog.registry import AuditlogModelRegistry
LogEntry = get_logentry_model()
class JSONForChangesTest(TestCase):

View file

@ -4,7 +4,7 @@ import json
import random
import warnings
from datetime import timezone
from unittest import mock
from unittest import mock, skipIf
from unittest.mock import patch
import freezegun
@ -17,6 +17,8 @@ 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.core.exceptions import ImproperlyConfigured
from django.core.management import call_command
from django.db import models
from django.db.models import JSONField, Value
from django.db.models.functions import Now
@ -45,7 +47,10 @@ from test_app.models import (
NullableJSONModel,
ProxyModel,
RelatedModel,
RelatedModelParent,
ReusableThroughRelatedModel,
SecretM2MModel,
SecretRelatedModel,
SerializeNaturalKeyRelatedModel,
SerializeOnlySomeOfThisModel,
SerializePrimaryKeyRelatedModel,
@ -60,15 +65,18 @@ from test_app.models import (
UUIDPrimaryKeyModel,
)
from auditlog import get_logentry_model
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_extra_data
from auditlog.diff import mask_str, model_instance_diff
from auditlog.middleware import AuditlogMiddleware
from auditlog.models import DEFAULT_OBJECT_REPR, LogEntry
from auditlog.models import DEFAULT_OBJECT_REPR
from auditlog.registry import AuditlogModelRegistry, AuditLogRegistrationError, auditlog
from auditlog.signals import post_log, pre_log
LogEntry = get_logentry_model()
class SimpleModelTest(TestCase):
def setUp(self):
@ -123,6 +131,11 @@ class SimpleModelTest(TestCase):
{"boolean": ["False", "True"]},
msg="The change is correctly logged",
)
self.assertEqual(
history.changes_str,
"boolean: False → True",
msg="Changes string is correct",
)
def test_update_specific_field_supplied_via_save_method(self):
obj = self.obj
@ -141,6 +154,11 @@ class SimpleModelTest(TestCase):
"when using the `update_fields`."
),
)
self.assertEqual(
obj.history.get(action=LogEntry.Action.UPDATE).changes_str,
"boolean: False → True",
msg="Changes string is correct",
)
def test_django_update_fields_edge_cases(self):
"""
@ -171,6 +189,11 @@ class SimpleModelTest(TestCase):
{"boolean": ["False", "True"], "integer": ["None", "1"]},
msg="The 2 fields changed are correctly logged",
)
self.assertEqual(
obj.history.get(action=LogEntry.Action.UPDATE).changes_str,
"boolean: False → True; integer: None → 1",
msg="Changes string is correct",
)
def test_delete(self):
"""Deletion is logged correctly."""
@ -258,7 +281,7 @@ class NoActorMixin:
self.assertIsNone(log_entry.actor)
class WithActorMixin:
class WithActorMixinBase:
sequence = itertools.count()
def setUp(self):
@ -277,10 +300,6 @@ class WithActorMixin:
self.assertIsNotNone(auditlog_entries, msg="All auditlog entries are deleted.")
super().tearDown()
def make_object(self):
with set_actor(self.user):
return super().make_object()
def check_create_log_entry(self, obj, log_entry):
super().check_create_log_entry(obj, log_entry)
self.assertEqual(log_entry.actor, self.user)
@ -305,6 +324,12 @@ class WithActorMixin:
self.assertEqual(log_entry.actor_email, self.user.email)
class WithActorMixin(WithActorMixinBase):
def make_object(self):
with set_actor(self.user):
return super().make_object()
class AltPrimaryKeyModelBase(SimpleModelTest):
def make_object(self):
return AltPrimaryKeyModel.objects.create(
@ -369,6 +394,10 @@ class ModelPrimaryKeyModelWithActorTest(WithActorMixin, ModelPrimaryKeyModelBase
# Must inherit from TransactionTestCase to use self.assertNumQueries.
class ModelPrimaryKeyTest(TransactionTestCase):
def _fixture_teardown(self):
call_command("flush", verbosity=0, interactive=False, allow_cascade=True)
def test_get_pk_value(self):
"""
Test that the primary key can be retrieved without additional database queries.
@ -480,6 +509,13 @@ class ManyRelatedModelTest(TestCase):
},
)
def test_changes_str(self):
self.obj.related.add(self.related)
log_entry = self.obj.history.first()
self.assertEqual(
log_entry.changes_str, f"related: add {[smart_str(self.related)]}"
)
def test_adding_existing_related_obj(self):
self.obj.related.add(self.related)
log_entry = self.obj.history.first()
@ -711,6 +747,11 @@ class SimpleIncludeModelTest(TestCase):
{"label": ["Initial label", "New label"]},
msg="Only the label was logged, regardless of multiple entries in `update_fields`",
)
self.assertEqual(
obj.history.get(action=LogEntry.Action.UPDATE).changes_str,
"label: Initial label → New label",
msg="Changes string is correct",
)
def test_register_include_fields(self):
sim = SimpleIncludeModel(label="Include model", text="Looong text")
@ -1358,7 +1399,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(
@ -1739,21 +1780,24 @@ class AdminPanelTest(TestCase):
)
self.site = AdminSite()
self.admin = LogEntryAdmin(LogEntry, self.site)
self.admin_path_prefix = (
f"admin/{LogEntry._meta.app_label}/{LogEntry._meta.model_name}"
)
with freezegun.freeze_time("2022-08-01 12:00:00Z"):
self.obj = SimpleModel.objects.create(text="For admin logentry test")
def test_auditlog_admin(self):
self.client.force_login(self.user)
log_pk = self.obj.history.latest().pk
res = self.client.get("/admin/auditlog/logentry/")
res = self.client.get(f"/{self.admin_path_prefix}/")
self.assertEqual(res.status_code, 200)
res = self.client.get("/admin/auditlog/logentry/add/")
res = self.client.get(f"/{self.admin_path_prefix}/add/")
self.assertEqual(res.status_code, 403)
res = self.client.get(f"/admin/auditlog/logentry/{log_pk}/", follow=True)
res = self.client.get(f"/{self.admin_path_prefix}/{log_pk}/", follow=True)
self.assertEqual(res.status_code, 200)
res = self.client.get(f"/admin/auditlog/logentry/{log_pk}/delete/")
res = self.client.get(f"/{self.admin_path_prefix}/{log_pk}/delete/")
self.assertEqual(res.status_code, 403)
res = self.client.get(f"/admin/auditlog/logentry/{log_pk}/history/")
res = self.client.get(f"/{self.admin_path_prefix}/{log_pk}/history/")
self.assertEqual(res.status_code, 200)
def test_created_timezone(self):
@ -1783,7 +1827,7 @@ class AdminPanelTest(TestCase):
def test_cid(self):
self.client.force_login(self.user)
expected_response = (
'<a href="/admin/auditlog/logentry/?cid=123" '
f'<a href="/{self.admin_path_prefix}/?cid=123" '
'title="Click to filter by records with this correlation id">123</a>'
)
@ -1791,7 +1835,7 @@ class AdminPanelTest(TestCase):
log_entry.cid = "123"
log_entry.save()
res = self.client.get("/admin/auditlog/logentry/")
res = self.client.get(f"/{self.admin_path_prefix}/")
self.assertEqual(res.status_code, 200)
self.assertIn(expected_response, res.rendered_content)
@ -1799,7 +1843,7 @@ class AdminPanelTest(TestCase):
log = self.obj.history.latest()
obj_pk = self.obj.pk
delete_log_request = RequestFactory().post(
f"/admin/auditlog/logentry/{log.pk}/delete/"
f"/{self.admin_path_prefix}/{log.pk}/delete/"
)
delete_log_request.resolver_match = resolve(delete_log_request.path)
delete_log_request.user = self.user
@ -2044,6 +2088,11 @@ class JSONModelTest(TestCase):
{"json": ["{}", '{"quantity": "1"}']},
msg="The change is correctly logged",
)
self.assertEqual(
history.changes_str,
'json: {}{"quantity": "1"}',
msg="Changes string is correct",
)
def test_update_with_no_changes(self):
"""No changes are logged."""
@ -2115,6 +2164,27 @@ class ModelInstanceDiffTest(TestCase):
model_instance_diff(simple2, simple1)
model_instance_diff(simple1, simple2)
def test_diff_polymorphic_models(self):
"""No error is raised when comparing parent/child for polymorphic models."""
# This tests that when a polymorphic model is compared to its parent,
# no FieldDoesNotExist errors are raised because those fields don't exist
# on the parent model.
# relation target
simple = SimpleModel()
simple.save()
# the parent model
related_parent = RelatedModelParent()
related_parent.save()
# the child model, with some fields that don't exist on the parent
related = RelatedModel(related=simple, one_to_one=simple)
related.save()
model_instance_diff(related, related_parent)
def test_object_repr_related_deleted(self):
"""No error is raised when __str__() loads a related object that has been deleted."""
simple = SimpleModel()
@ -2369,6 +2439,29 @@ class TestRelatedDiffs(TestCase):
self.assertEqual(int(log_create.changes_dict["related"][1]), one_simple.id)
self.assertEqual(int(log_update.changes_dict["related"][1]), two_simple.id)
@override_settings(AUDITLOG_USE_FK_STRING_REPRESENTATION=True)
def test_string_representation_of_fk_changes(self):
"""FK changes should be stored using string representation when setting is enabled"""
t1 = self.test_date
with freezegun.freeze_time(t1):
simple = SimpleModel.objects.create(text="Test Foo")
two_simple = SimpleModel.objects.create(text="Test Bar")
instance = RelatedModel.objects.create(one_to_one=simple, related=simple)
t2 = self.test_date + datetime.timedelta(days=20)
with freezegun.freeze_time(t2):
instance.one_to_one = two_simple
instance.related = two_simple
instance.save()
self.assertEqual(instance.history.all().count(), 2)
log_update = instance.history.filter(timestamp=t2).first()
self.assertEqual(log_update.changes_dict["related"][0], "Test Foo")
self.assertEqual(log_update.changes_dict["related"][1], "Test Bar")
self.assertEqual(log_update.changes_dict["one_to_one"][0], "Test Foo")
self.assertEqual(log_update.changes_dict["one_to_one"][1], "Test Bar")
class TestModelSerialization(TestCase):
def setUp(self):
@ -2636,6 +2729,7 @@ class TestAccessLog(TestCase):
)
self.assertIsNone(log_entry.changes)
self.assertEqual(log_entry.changes_dict, {})
self.assertEqual(log_entry.changes_str, "")
class SignalTests(TestCase):
@ -2796,7 +2890,7 @@ class SignalTests(TestCase):
self.assertSignals(LogEntry.Action.DELETE)
@patch("auditlog.receivers.LogEntry.objects")
@patch.object(LogEntry, "objects")
def test_signals_errors(self, log_entry_objects_mock):
class CustomSignalError(BaseException):
pass
@ -2931,6 +3025,139 @@ 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)],
}
},
)
self.assertEqual(
log_entry.changes_str, f"m2m_related: add {[smart_str(obj_two)]}"
)
class TestMaskStr(TestCase):
"""Test the mask_str function that masks sensitive data."""
@ -2988,3 +3215,91 @@ class CustomMaskModelTest(TestCase):
"****7654",
msg="The custom masking function should be used in serialized data",
)
class WithExtraDataMixin(WithActorMixinBase):
def get_context_data(self):
return {}
def make_object(self):
with set_extra_data(context_data=self.get_context_data()):
return super().make_object()
class ExtraDataTest(WithExtraDataMixin, SimpleModelTest):
def get_context_data(self):
return {
"actor": self.user,
}
class ExtraDataWithRoleTest(WithExtraDataMixin, SimpleModelTest):
def get_context_data(self):
return {
"actor": self.user,
"role": "admin",
}
@skipIf(
settings.AUDITLOG_LOGENTRY_MODEL == "auditlog.LogEntry",
"Do not run on defualt log entry model",
)
def test_extra_data_role(self):
log = self.obj.history.first()
self.assertEqual(log.role, "admin")
class ExtraDataWithRoleLazyLoadTest(WithExtraDataMixin, SimpleModelTest):
def get_context_data(self):
return {
"actor": self.user,
"role": lambda: "admin",
}
@skipIf(
settings.AUDITLOG_LOGENTRY_MODEL == "auditlog.LogEntry",
"Do not run on defualt log entry model",
)
def test_extra_data_role(self):
log = self.obj.history.first()
self.assertEqual(log.role, "admin")
class GetLogEntryModelTest(TestCase):
"""Test the get_logentry_model function."""
def get_model_name(self):
model = get_logentry_model()
return f"{model._meta.app_label}.{model._meta.object_name}"
def test_logentry_model(self):
self.assertEqual(self.get_model_name(), settings.AUDITLOG_LOGENTRY_MODEL)
@override_settings(AUDITLOG_LOGENTRY_MODEL="LogEntry")
def test_invalid_logentry_model_name(self):
with self.assertRaises(ImproperlyConfigured):
get_logentry_model()
@override_settings(AUDITLOG_LOGENTRY_MODEL="test_app2.LogEntry")
def test_invalid_appname(self):
with self.assertRaises(ImproperlyConfigured):
get_logentry_model()
def test_logentry_model_default_when_setting_missing(self):
"""Regression test for issue #788: AttributeError when AUDITLOG_LOGENTRY_MODEL is not set."""
# Save and remove the setting to simulate the bug condition
original_value = getattr(settings, "AUDITLOG_LOGENTRY_MODEL", None)
if hasattr(settings, "AUDITLOG_LOGENTRY_MODEL"):
delattr(settings, "AUDITLOG_LOGENTRY_MODEL")
try:
# This should NOT raise AttributeError - it should use the default
model = get_logentry_model()
self.assertEqual(
f"{model._meta.app_label}.{model._meta.object_name}",
"auditlog.LogEntry",
)
finally:
# Restore the original setting
if original_value is not None:
settings.AUDITLOG_LOGENTRY_MODEL = original_value

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

@ -76,7 +76,7 @@ You can also add log-access to function base views, as the following example ill
Fields that are excluded will not trigger saving a new log entry and will not show up in the recorded changes.
To exclude specific fields from the log you can pass ``include_fields`` resp. ``exclude_fields`` to the ``register``
To exclude specific fields from the log you can pass ``include_fields`` or ``exclude_fields`` to the ``register``
method. If ``exclude_fields`` is specified the fields with the given names will not be included in the generated log
entries. If ``include_fields`` is specified only the fields with the given names will be included in the generated log
entries. Explicitly excluding fields through ``exclude_fields`` takes precedence over specifying which fields to
@ -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,
@ -253,7 +253,7 @@ It will be considered when ``AUDITLOG_INCLUDE_ALL_MODELS`` is `True`.
.. versionadded:: 3.0.0
**AUDITLOG_EXCLUDE_TRACKING_FIELDS**
**AUDITLOG_DISABLE_REMOTE_ADDR**
When using "AuditlogMiddleware",
the IP address is logged by default, you can use this setting
@ -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,108 @@ 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``
**AUDITLOG_USE_FK_STRING_REPRESENTATION**
Determines how changes to foreign key fields are recorded in log entries.
When `True`, changes to foreign key fields are stored using the string representation of related objects.
When `False` (default), the primary key of the related objects is stored instead.
Before version 2.2.0, foreign key changes were stored using the string representation of the related objects.
Starting from version 2.2.0, the default behavior was updated to store the primary key of the related objects instead.
Before:
.. code-block:: json
{ "foreign_key_field": ["foo", "bar"] }
After:
.. code-block:: json
{ "foreign_key_field": [1, 2] }
You can use this option to enable the legacy behavior.
.. warning::
This reintroduces a known issue https://github.com/jazzband/django-auditlog/issues/421
Commission Error: Causes unnecessary LogEntries even though no update occurrs because the string representation in memory changed
Omission Error: More common problem, a related object is updated to another object with the same string representation, no update is logged
Beware of these problem when enabling this setting.
.. versionadded:: 3.4.0
Actors
------

View file

@ -10,11 +10,13 @@ setup(
name="django-auditlog",
use_scm_version={"version_scheme": "post-release"},
setup_requires=["setuptools_scm"],
include_package_data=True,
packages=[
"auditlog",
"auditlog.migrations",
"auditlog.management",
"auditlog.management.commands",
"auditlog.templatetags",
],
url="https://github.com/jazzband/django-auditlog",
project_urls={
@ -27,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",

19
tox.ini
View file

@ -1,17 +1,20 @@
[tox]
envlist =
{py39,py310,py311}-django42
{py312}-customlogmodel-django52
{py310,py311}-django42
{py310,py311,py312}-django50
{py310,py311,py312,py313}-django51
{py310,py311,py312,py313}-django52
{py312,py313}-djangomain
py39-docs
py39-lint
py39-checkmigrations
py310-docs
py310-lint
py310-checkmigrations
[testenv]
setenv =
COVERAGE_FILE={toxworkdir}/.coverage.{envname}.{env:TEST_DB_BACKEND}
customlogmodel: AUDITLOG_LOGENTRY_MODEL = custom_logentry_app.CustomLogEntryModel
changedir = auditlog_tests
commands =
coverage run --source auditlog ./manage.py test
@ -42,19 +45,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 =
@ -73,7 +75,6 @@ commands =
[gh-actions]
python =
3.9: py39
3.10: py310
3.11: py311
3.12: py312