Compare commits

...

34 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
Youngkwang Yang
d87b92923e
Prepare release 3.3.0 (#756) 2025-10-02 19:02:19 +02:00
Youngkwang Yang
3051d230b9
Fix None type mismatch in change detection (#763)
* Add test model and test cases for None value type mismatch

- issue #750 @The-Alchemist

* Fix None type mismatch in change detection
2025-10-01 20:50:54 +02:00
Youngkwang Yang
65ebec6663
Add I18N Support (#762)
* Add locale files

* Add Korean translations

* Update translation files using LLM

* Add missing gettext wrappers for UI strings

* Add i18n support for audit log table headers

* Add justfile for i18n workflow

* Add `.mo` binary file

* Update CHANGELOG.md

* Add create-locale command to justfile for new language setup

* Replace justfile with Makefile
2025-09-30 16:15:45 +02:00
Youngkwang Yang
4de16fbd40
Fix Expression test compatibility for Django 6.0+ (#759)
* Skip incompatible tests on Django 6.0+

refs:
- #635
- #646
- https://code.djangoproject.com/ticket/27222

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

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

* remove skipif

* Add changelog

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-09-24 09:05:11 +02:00
dependabot[bot]
572aeebec7
Bump actions/setup-python from 5 to 6 in the github-actions group (#754)
Bumps the github-actions group with 1 update: [actions/setup-python](https://github.com/actions/setup-python).


Updates `actions/setup-python` from 5 to 6
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/setup-python
  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-09-08 14:08:01 +02:00
pre-commit-ci[bot]
5379e1e5d0
[pre-commit.ci] pre-commit autoupdate (#753)
updates:
- [github.com/adamchainz/django-upgrade: 1.25.0 → 1.27.0](https://github.com/adamchainz/django-upgrade/compare/1.25.0...1.27.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-09-01 20:27:15 +02:00
dependabot[bot]
a7c07a491d
Bump actions/checkout from 4 to 5 in the github-actions group (#752)
Bumps the github-actions group with 1 update: [actions/checkout](https://github.com/actions/checkout).


Updates `actions/checkout` from 4 to 5
- [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/v4...v5)

---
updated-dependencies:
- dependency-name: actions/checkout
  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-08-25 23:29:00 +02:00
Youngkwang Yang
8003b069c9
Extend CI and local test coverage to MySQL and SQLite (#744)
* Add test runner and improve test with multi databases

* Enhance cross-database compatibility and testing

- Fix TRUNCATE command support detection for different databases
- Add conditional PostgreSQL-specific model registration
- Improve database-specific test skipping logic
- Remove SQLite from TRUNCATE supported vendors list

* Add docker compose for testing

* Improve CI/CD with multi-database support

- Add separate test workflows for SQLite, PostgreSQL, and MySQL

* Add `mysqlclient` deps

* fix minor

- Add mysqlclient deps
- upload coverage step

* Fix coverage upload name conflicts in CI workflow

- Add database type to coverage upload names (SQLite/PostgreSQL/MySQL)
2025-08-17 16:50:23 +02:00
Youngkwang Yang
9ef8cf2476
Add audit log history view to Django Admin (#743)
* split auditlog HTML render logic from Admin mixin into reusable functions

* add AuditlogHistoryAdminMixin class

* add test cases for auditlog html render functions

* add audit log history view documentation

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

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

* Fix minor

* Add missing versionadded and configuration options for AuditlogHistoryAdminMixin

* Add missing test cases

* Update versionadded to 3.2.2 for AuditlogHistoryAdminMixin

* Update CHANGELOG.md

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-08-05 13:02:43 +02:00
The Alchemist
d4d9f287a6
added fixes for changes_display_dict when AUDITLOG_STORE_JSON_CHANGES is True (#738) 2025-07-22 23:19:40 +02:00
Edward Betts
0eff3e8404
Use canonical timezone name for Buenos Aires (#736)
Debian has moved legacy timezone names such as America/Buenos_Aires into
the tzdata-legacy package, which is not installed by default. The
canonical name for this timezone is America/Argentina/Buenos_Aires,
which remains in the standard tzdata package.

Update the test to use the canonical name to ensure compatibility with
default Debian installations.
2025-07-11 15:02:58 +02:00
57 changed files with 2630 additions and 351 deletions

View file

@ -0,0 +1,30 @@
name: 'Setup Python and Dependencies'
description: 'Common setup steps for Python and pip dependencies'
inputs:
python-version:
description: 'Python version to setup'
required: true
cache-key-prefix:
description: 'Prefix for pip cache key'
required: true
runs:
using: 'composite'
steps:
- name: Set up Python ${{ inputs.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ inputs.python-version }}
- name: Cache pip
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ inputs.cache-key-prefix }}-${{ inputs.python-version }}-${{ hashFiles('**/pyproject.toml') }}
- name: Install Python dependencies
shell: bash
run: |
python -m pip install --upgrade pip
python -m pip install --upgrade tox tox-gh-actions

View file

@ -11,14 +11,14 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v6
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v5 uses: actions/setup-python@v6
with: with:
python-version: '3.9' python-version: '3.10'
- name: Get pip cache dir - name: Get pip cache dir
id: pip-cache id: pip-cache
@ -26,7 +26,7 @@ jobs:
echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT
- name: Cache - name: Cache
uses: actions/cache@v4 uses: actions/cache@v5
with: with:
path: ${{ steps.pip-cache.outputs.dir }} path: ${{ steps.pip-cache.outputs.dir }}
key: release-${{ hashFiles('**/setup.py') }} key: release-${{ hashFiles('**/setup.py') }}

View file

@ -3,67 +3,125 @@ name: Test
on: [push, pull_request] on: [push, pull_request]
jobs: jobs:
build: test-sqlite:
name: SQLite • Python ${{ matrix.python-version }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
fail-fast: false fail-fast: false
max-parallel: 5
matrix: 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@v6
- name: Setup Python and dependencies
uses: ./.github/actions/setup-python-deps
with:
python-version: ${{ matrix.python-version }}
cache-key-prefix: sqlite3
- name: Run tests
env:
TEST_DB_BACKEND: sqlite3
run: tox -v
- name: Upload coverage
uses: codecov/codecov-action@v5
with:
name: SQLite • Python ${{ matrix.python-version }}
test-postgres:
name: PostgreSQL • Python ${{ matrix.python-version }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13"]
services: services:
postgres: postgres:
image: postgres:14 image: postgres:15
env: env:
POSTGRES_USER: postgres POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres POSTGRES_PASSWORD: postgres
POSTGRES_DB: postgres POSTGRES_DB: auditlog
ports: ports:
- 5432/tcp - 5432/tcp
options: >- options: >-
--health-cmd pg_isready --health-cmd pg_isready
--health-interval 10s --health-interval 10s
--health-timeout 5s --health-timeout 5s
--health-retries 5 --health-retries 10
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v6
- name: Set up Python ${{ matrix.python-version }} - name: Setup Python and dependencies
uses: actions/setup-python@v5 uses: ./.github/actions/setup-python-deps
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
cache-key-prefix: postgresql
- name: Get pip cache dir - name: Run tests
id: pip-cache env:
run: | TEST_DB_BACKEND: postgresql
echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT TEST_DB_HOST: localhost
TEST_DB_USER: postgres
TEST_DB_PASS: postgres
TEST_DB_NAME: auditlog
TEST_DB_PORT: ${{ job.services.postgres.ports[5432] }}
- name: Cache run: tox -v
uses: actions/cache@v4
with:
path: ${{ steps.pip-cache.outputs.dir }}
key:
-${{ matrix.python-version }}-v1-${{ hashFiles('**/setup.py') }}
restore-keys: |
-${{ matrix.python-version }}-v1-
- name: Install dependencies - name: Upload coverage
run: | uses: codecov/codecov-action@v5
python -m pip install --upgrade pip with:
python -m pip install --upgrade tox tox-gh-actions name: PostgreSQL • Python ${{ matrix.python-version }}
- name: Tox tests test-mysql:
run: | name: MySQL • Python ${{ matrix.python-version }}
tox -v runs-on: ubuntu-latest
env: strategy:
TEST_DB_HOST: localhost fail-fast: false
TEST_DB_USER: postgres matrix:
TEST_DB_PASS: postgres python-version: ["3.10", "3.11", "3.12", "3.13"]
TEST_DB_NAME: postgres services:
TEST_DB_PORT: ${{ job.services.postgres.ports[5432] }} mysql:
image: mysql:8.4
env:
MYSQL_DATABASE: auditlog
MYSQL_USER: mysql
MYSQL_PASSWORD: mysql
MYSQL_ROOT_PASSWORD: mysql
ports:
- 3306/tcp
options: >-
--health-cmd="sh -c 'export MYSQL_PWD=\"$MYSQL_ROOT_PASSWORD\"; mysqladmin ping -h 127.0.0.1 --protocol=TCP -uroot --silent || exit 1'"
--health-interval=10s
--health-timeout=5s
--health-retries=20
steps:
- uses: actions/checkout@v6
- name: Upload coverage - name: Install MySQL client libraries
uses: codecov/codecov-action@v5 run: |
with: sudo apt-get update
name: Python ${{ matrix.python-version }} sudo apt-get install -y libmysqlclient-dev pkg-config mysql-client
- name: Setup Python and dependencies
uses: ./.github/actions/setup-python-deps
with:
python-version: ${{ matrix.python-version }}
cache-key-prefix: mysql
- name: Run tests
env:
TEST_DB_BACKEND: mysql
TEST_DB_HOST: 127.0.0.1
TEST_DB_USER: root
TEST_DB_PASS: mysql
TEST_DB_NAME: auditlog
TEST_DB_PORT: ${{ job.services.mysql.ports[3306] }}
run: tox -v
- name: Upload coverage
uses: codecov/codecov-action@v5
with:
name: MySQL • Python ${{ matrix.python-version }}

1
.gitignore vendored
View file

@ -48,7 +48,6 @@ coverage.xml
cover/ cover/
# Translations # Translations
*.mo
*.pot *.pot
# Django stuff: # Django stuff:

View file

@ -1,29 +1,29 @@
--- ---
repos: repos:
- repo: https://github.com/psf/black - repo: https://github.com/psf/black-pre-commit-mirror
rev: 25.1.0 rev: 26.3.0
hooks: hooks:
- id: black - id: black
language_version: python3.9 language_version: python3.10
args: args:
- "--target-version" - "--target-version"
- "py39" - "py310"
- repo: https://github.com/PyCQA/flake8 - repo: https://github.com/PyCQA/flake8
rev: "7.3.0" rev: "7.3.0"
hooks: hooks:
- id: flake8 - id: flake8
args: ["--max-line-length", "110"] args: ["--max-line-length", "110"]
- repo: https://github.com/PyCQA/isort - repo: https://github.com/PyCQA/isort
rev: 6.0.1 rev: 8.0.1
hooks: hooks:
- id: isort - id: isort
- repo: https://github.com/asottile/pyupgrade - repo: https://github.com/asottile/pyupgrade
rev: v3.20.0 rev: v3.21.2
hooks: hooks:
- id: pyupgrade - id: pyupgrade
args: [--py39-plus] args: [--py310-plus]
- repo: https://github.com/adamchainz/django-upgrade - repo: https://github.com/adamchainz/django-upgrade
rev: 1.25.0 rev: 1.30.0
hooks: hooks:
- id: django-upgrade - id: django-upgrade
args: [--target-version, "4.2"] args: [--target-version, "4.2"]

View file

@ -2,6 +2,39 @@
## Next Release ## 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
- CI: Extend CI and local test coverage to MySQL and SQLite ([#744](https://github.com/jazzband/django-auditlog/pull/744))
- feat: Add audit log history view to Django Admin. ([#743](https://github.com/jazzband/django-auditlog/pull/743))
- Fix Expression test compatibility for Django 6.0+ ([#759](https://github.com/jazzband/django-auditlog/pull/759))
- Add I18N Support ([#762](https://github.com/jazzband/django-auditlog/pull/762))
## 3.2.1 (2025-07-03) ## 3.2.1 (2025-07-03)
#### Improvements #### Improvements

3
MANIFEST.in Normal file
View file

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

42
Makefile Normal file
View file

@ -0,0 +1,42 @@
# Django Auditlog Makefile
# Default target shows help
.DEFAULT_GOAL := help
.PHONY: help install test makemessages compilemessages create-locale i18n clean
# Variables
AUDITLOG_DIR := auditlog
install: ## Install dependencies
pip install -e .
test: ## Run tests
./runtests.sh
makemessages: ## Extract translatable strings and create/update .po files for all languages
cd $(AUDITLOG_DIR) && \
django-admin makemessages --add-location=file -a --ignore=__pycache__ --ignore=migrations
compilemessages: ## Compile all translation files (.po to .mo)
cd $(AUDITLOG_DIR) && \
django-admin compilemessages
create-locale: ## Create initial locale structure for a new language (requires LANG=<code>)
@if [ -z "$(LANG)" ]; then \
echo "Error: LANG parameter is required. Usage: make create-locale LANG=<language_code>"; \
echo "Examples: make create-locale LANG=ko, make create-locale LANG=ja"; \
exit 1; \
fi
mkdir -p $(AUDITLOG_DIR)/locale/$(LANG)/LC_MESSAGES
cd $(AUDITLOG_DIR) && \
django-admin makemessages --add-location=file -l $(LANG) --ignore=__pycache__ --ignore=migrations
i18n: makemessages compilemessages ## Full i18n workflow: extract strings, compile messages
clean: ## Clean compiled translation files (.mo files)
find $(AUDITLOG_DIR)/locale -name "*.mo" -delete
help: ## Help message for targets
@echo "Available commands:"
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
| awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'

View file

@ -1,3 +1,24 @@
from __future__ import annotations
from importlib.metadata import version 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") __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.contrib.auth import get_user_model
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from auditlog import get_logentry_model
from auditlog.filters import CIDFilter, ResourceTypeFilter from auditlog.filters import CIDFilter, ResourceTypeFilter
from auditlog.mixins import LogEntryAdminMixin from auditlog.mixins import LogEntryAdminMixin
from auditlog.models import LogEntry
LogEntry = get_logentry_model()
@admin.register(LogEntry) @admin.register(LogEntry)

View file

@ -1,5 +1,4 @@
from contextvars import ContextVar from contextvars import ContextVar
from typing import Optional
from django.conf import settings from django.conf import settings
from django.http import HttpRequest 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) 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. A function to read the cid from a request.
If the header is not in the request, then we set it to `None`. 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) correlation_id.set(cid)
def _get_cid() -> Optional[str]: def _get_cid() -> str | None:
return correlation_id.get() 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` 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_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.contrib.auth import get_user_model
from django.db.models.signals import pre_save 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_value = ContextVar("auditlog_value")
auditlog_disabled = ContextVar("auditlog_disabled", default=False) auditlog_disabled = ContextVar("auditlog_disabled", default=False)
@ -14,23 +14,33 @@ auditlog_disabled = ContextVar("auditlog_disabled", default=False)
@contextlib.contextmanager @contextlib.contextmanager
def set_actor(actor, remote_addr=None, remote_port=None): def set_actor(actor, remote_addr=None, remote_port=None):
"""Connect a signal receiver with current user attached."""
# Initialize thread local storage
context_data = { context_data = {
"signal_duid": ("set_actor", time.time()), "actor": actor,
"remote_addr": remote_addr, "remote_addr": remote_addr,
"remote_port": remote_port, "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) auditlog_value.set(context_data)
# Connect signal for automatic logging # Connect signal for automatic logging
set_actor = partial( set_extra_data = partial(
_set_actor, _set_extra_data,
user=actor,
signal_duid=context_data["signal_duid"], signal_duid=context_data["signal_duid"],
) )
pre_save.connect( pre_save.connect(
set_actor, set_extra_data,
sender=LogEntry, sender=LogEntry,
dispatch_uid=context_data["signal_duid"], dispatch_uid=context_data["signal_duid"],
weak=False, 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"]) 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. """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. This function becomes a valid signal receiver when it is curried with the actor and a dispatch id.
""" """
LogEntry = get_logentry_model()
try: try:
auditlog = auditlog_value.get() auditlog = auditlog_value.get()
except LookupError: except LookupError:
@ -59,17 +84,15 @@ def _set_actor(user, sender, instance, signal_duid, **kwargs):
else: else:
if signal_duid != auditlog["signal_duid"]: if signal_duid != auditlog["signal_duid"]:
return 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"] _set_actor(auditlog, instance, sender)
instance.remote_port = auditlog["remote_port"]
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 @contextlib.contextmanager

View file

@ -1,14 +1,16 @@
import json import json
from collections.abc import Callable
from datetime import timezone from datetime import timezone
from typing import Callable, Optional
from django.conf import settings 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.db.models import NOT_PROVIDED, DateTimeField, ForeignKey, JSONField, Model
from django.utils import timezone as django_timezone from django.utils import timezone as django_timezone
from django.utils.encoding import smart_str from django.utils.encoding import smart_str
from django.utils.module_loading import import_string from django.utils.module_loading import import_string
from auditlog import get_logentry_model
def track_field(field): def track_field(field):
""" """
@ -21,7 +23,6 @@ def track_field(field):
:return: Whether the given field should be tracked. :return: Whether the given field should be tracked.
:rtype: bool :rtype: bool
""" """
from auditlog.models import LogEntry
# Do not track many to many relations # Do not track many to many relations
if field.many_to_many: if field.many_to_many:
@ -30,7 +31,7 @@ def track_field(field):
# Do not track relations to LogEntry # Do not track relations to LogEntry
if ( if (
getattr(field, "remote_field", None) is not None getattr(field, "remote_field", None) is not None
and field.remote_field.model == LogEntry and field.remote_field.model == get_logentry_model()
): ):
return False return False
@ -73,15 +74,15 @@ def get_field_value(obj, field, use_json_for_changes=False):
try: try:
model_field = obj._meta.get_field(field.name) model_field = obj._meta.get_field(field.name)
default = model_field.default default = model_field.default
if default is NOT_PROVIDED: except (AttributeError, FieldDoesNotExist):
return None default = NOT_PROVIDED
if callable(default): if default is NOT_PROVIDED:
return default() default = None
elif callable(default):
default = default()
return default return smart_str(default) if not use_json_for_changes else default
except AttributeError:
return None
try: try:
if isinstance(field, DateTimeField): if isinstance(field, DateTimeField):
@ -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) value = json.dumps(value, sort_keys=True, cls=field.encoder)
except TypeError: except TypeError:
pass 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) value = smart_str(getattr(obj, field.get_attname()), strings_only=True)
else: else:
value = getattr(obj, field.name) value = getattr(obj, field.name)
@ -131,7 +136,7 @@ def is_primitive(obj) -> bool:
return isinstance(obj, primitive_types) 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: Get the masking function to use based on the following priority:
1. Model-specific mask_callable if provided 1. Model-specific mask_callable if provided
@ -168,8 +173,8 @@ def mask_str(value: str) -> str:
def model_instance_diff( def model_instance_diff(
old: Optional[Model], old: Model | None,
new: Optional[Model], new: Model | None,
fields_to_check=None, fields_to_check=None,
use_json_for_changes=False, use_json_for_changes=False,
): ):

Binary file not shown.

View file

@ -0,0 +1,192 @@
# Django Auditlog Japanese Translation
# Copyright (C) 2025 Django Auditlog Contributors
# This file is distributed under the same license as the django-auditlog package.
# Youngkwang Yang <me@youngkwang.dev>, 2025.
#
msgid ""
msgstr ""
"Project-Id-Version: django-auditlog\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-09-28 03:43+0900\n"
"PO-Revision-Date: 2025-09-28 03:16+0900\n"
"Last-Translator: Youngkwang Yang <me@youngkwang.dev>\n"
"Language-Team: Japanese <ja@li.org>\n"
"Language: ja\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=1; plural=0;\n"
#: admin.py mixins.py
msgid "Changes"
msgstr "変更"
#: apps.py mixins.py templates/auditlog/object_history.html
msgid "Audit log"
msgstr "監査ログ"
#: filters.py
msgid "Resource Type"
msgstr "リソースタイプ"
#: filters.py models.py
msgid "Correlation ID"
msgstr "Correlation ID"
#: mixins.py
msgid "Click to filter by records with this correlation id"
msgstr "このCorrelation IDでレコードをフィルタするにはクリックしてください"
#: mixins.py
msgid "Created"
msgstr "作成済み"
#: mixins.py
msgid "User"
msgstr "ユーザー"
#: mixins.py
msgid "Resource"
msgstr "リソース"
#: mixins.py
#, python-format
msgid "Audit log: %s"
msgstr "監査ログ:%s"
#: mixins.py
msgid "View"
msgstr "表示"
#: models.py
msgid "create"
msgstr "作成"
#: models.py
msgid "update"
msgstr "更新"
#: models.py
msgid "delete"
msgstr "削除"
#: models.py
msgid "access"
msgstr "アクセス"
#: models.py
msgid "content type"
msgstr "コンテンツタイプ"
#: models.py
msgid "object pk"
msgstr "オブジェクトPK"
#: models.py
msgid "object id"
msgstr "オブジェクトID"
#: models.py
msgid "object representation"
msgstr "オブジェクト表現"
#: models.py
msgid "action"
msgstr "アクション"
#: models.py
msgid "change message"
msgstr "変更メッセージ"
#: models.py
msgid "actor"
msgstr "アクター"
#: models.py
msgid "remote address"
msgstr "リモートアドレス"
#: models.py
msgid "remote port"
msgstr "リモートポート"
#: models.py
msgid "timestamp"
msgstr "タイムスタンプ"
#: models.py
msgid "additional data"
msgstr "追加データ"
#: models.py
msgid "actor email"
msgstr "アクターメール"
#: models.py
msgid "log entry"
msgstr "ログエントリ"
#: models.py
msgid "log entries"
msgstr "ログエントリ"
#: models.py
msgid "Created {repr:s}"
msgstr "{repr:s}が作成されました"
#: models.py
msgid "Updated {repr:s}"
msgstr "{repr:s}が更新されました"
#: models.py
msgid "Deleted {repr:s}"
msgstr "{repr:s}が削除されました"
#: models.py
msgid "Logged {repr:s}"
msgstr "{repr:s}がログに記録されました"
#: render.py
msgid "Field"
msgstr "フィールド"
#: render.py
msgid "From"
msgstr "変更前の値"
#: render.py
msgid "To"
msgstr "変更後の値"
#: render.py
msgid "Relationship"
msgstr "関係"
#: render.py
msgid "Action"
msgstr "アクション"
#: render.py
msgid "Objects"
msgstr "オブジェクト一覧"
#: templates/auditlog/entry_detail.html
msgid "system"
msgstr "システム"
#: templates/auditlog/entry_detail.html
msgid "No field changes"
msgstr "フィールドの変更なし"
#: templates/auditlog/object_history.html
msgid "Home"
msgstr "ホーム"
#: templates/auditlog/object_history.html
msgid "No log entries found."
msgstr "ログエントリが見つかりません。"
#: templates/auditlog/pagination.html
msgid "entry"
msgid_plural "entries"
msgstr[0] "ログエントリ"

Binary file not shown.

View file

@ -0,0 +1,192 @@
# Django Auditlog Korean Translation
# Copyright (C) 2025 Django Auditlog Contributors
# This file is distributed under the same license as the django-auditlog package.
# Youngkwang Yang <me@youngkwang.dev>, 2025.
#
msgid ""
msgstr ""
"Project-Id-Version: django-auditlog\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-09-28 03:43+0900\n"
"PO-Revision-Date: 2025-09-28 02:55+0900\n"
"Last-Translator: Youngkwang Yang <me@youngkwang.dev>\n"
"Language-Team: Korean <ko@li.org>\n"
"Language: ko\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=1; plural=0;\n"
#: admin.py mixins.py
msgid "Changes"
msgstr "변경 사항"
#: apps.py mixins.py templates/auditlog/object_history.html
msgid "Audit log"
msgstr "감사 로그"
#: filters.py
msgid "Resource Type"
msgstr "리소스 타입"
#: filters.py models.py
msgid "Correlation ID"
msgstr "Correlation ID"
#: mixins.py
msgid "Click to filter by records with this correlation id"
msgstr "이 Correlation ID로 레코드를 필터링하려면 클릭하세요"
#: mixins.py
msgid "Created"
msgstr "생성됨"
#: mixins.py
msgid "User"
msgstr "사용자"
#: mixins.py
msgid "Resource"
msgstr "리소스"
#: mixins.py
#, python-format
msgid "Audit log: %s"
msgstr "감사 로그: %s"
#: mixins.py
msgid "View"
msgstr "보기"
#: models.py
msgid "create"
msgstr "생성"
#: models.py
msgid "update"
msgstr "수정"
#: models.py
msgid "delete"
msgstr "삭제"
#: models.py
msgid "access"
msgstr "접근"
#: models.py
msgid "content type"
msgstr "콘텐츠 타입"
#: models.py
msgid "object pk"
msgstr "객체 PK"
#: models.py
msgid "object id"
msgstr "객체 ID"
#: models.py
msgid "object representation"
msgstr "객체 표현"
#: models.py
msgid "action"
msgstr "작업"
#: models.py
msgid "change message"
msgstr "변경 메시지"
#: models.py
msgid "actor"
msgstr "작업자"
#: models.py
msgid "remote address"
msgstr "원격 주소"
#: models.py
msgid "remote port"
msgstr "원격 포트"
#: models.py
msgid "timestamp"
msgstr "타임스탬프"
#: models.py
msgid "additional data"
msgstr "추가 데이터"
#: models.py
msgid "actor email"
msgstr "작업자 이메일"
#: models.py
msgid "log entry"
msgstr "로그 항목"
#: models.py
msgid "log entries"
msgstr "로그 항목들"
#: models.py
msgid "Created {repr:s}"
msgstr "{repr:s}이(가) 생성됨"
#: models.py
msgid "Updated {repr:s}"
msgstr "{repr:s}이(가) 수정됨"
#: models.py
msgid "Deleted {repr:s}"
msgstr "{repr:s}이(가) 삭제됨"
#: models.py
msgid "Logged {repr:s}"
msgstr "{repr:s}이(가) 기록됨"
#: render.py
msgid "Field"
msgstr "필드"
#: render.py
msgid "From"
msgstr "변경 전"
#: render.py
msgid "To"
msgstr "변경 후"
#: render.py
msgid "Relationship"
msgstr "관계"
#: render.py
msgid "Action"
msgstr "작업"
#: render.py
msgid "Objects"
msgstr "객체"
#: templates/auditlog/entry_detail.html
msgid "system"
msgstr "시스템"
#: templates/auditlog/entry_detail.html
msgid "No field changes"
msgstr "필드 변경 사항 없음"
#: templates/auditlog/object_history.html
msgid "Home"
msgstr "홈"
#: templates/auditlog/object_history.html
msgid "No log entries found."
msgstr "로그 항목을 찾을 수 없습니다."
#: templates/auditlog/pagination.html
msgid "entry"
msgid_plural "entries"
msgstr[0] "항목"

View file

@ -3,7 +3,9 @@ import datetime
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.db import connection from django.db import connection
from auditlog.models import LogEntry from auditlog import get_logentry_model
LogEntry = get_logentry_model()
class Command(BaseCommand): class Command(BaseCommand):
@ -83,7 +85,7 @@ class Command(BaseCommand):
class TruncateQuery: class TruncateQuery:
SUPPORTED_VENDORS = ("postgresql", "mysql", "sqlite", "oracle", "microsoft") SUPPORTED_VENDORS = ("postgresql", "mysql", "oracle", "microsoft")
@classmethod @classmethod
def support_truncate_statement(cls, database_vendor) -> bool: def support_truncate_statement(cls, database_vendor) -> bool:

View file

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

View file

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

View file

@ -1,19 +1,25 @@
from urllib.parse import unquote
from django import urls as urlresolvers from django import urls as urlresolvers
from django.conf import settings from django.conf import settings
from django.contrib import admin from django.contrib import admin
from django.core.exceptions import FieldDoesNotExist from django.contrib.admin.views.main import PAGE_VAR
from django.forms.utils import pretty_name from django.core.exceptions import PermissionDenied
from django.http import HttpRequest from django.http import HttpRequest
from django.template.response import TemplateResponse
from django.urls import path, reverse
from django.urls.exceptions import NoReverseMatch from django.urls.exceptions import NoReverseMatch
from django.utils.html import format_html, format_html_join from django.utils.html import format_html
from django.utils.safestring import mark_safe from django.utils.text import capfirst
from django.utils.timezone import is_aware, localtime from django.utils.timezone import is_aware, localtime
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from auditlog.models import LogEntry from auditlog import get_logentry_model
from auditlog.registry import auditlog from auditlog.render import get_field_verbose_name, render_logentry_changes_html
from auditlog.signals import accessed from auditlog.signals import accessed
LogEntry = get_logentry_model()
MAX = 75 MAX = 75
@ -68,55 +74,7 @@ class LogEntryAdminMixin:
@admin.display(description=_("Changes")) @admin.display(description=_("Changes"))
def msg(self, obj): def msg(self, obj):
changes = obj.changes_dict return render_logentry_changes_html(obj)
atom_changes = {}
m2m_changes = {}
for field, change in changes.items():
if isinstance(change, dict):
assert (
change["type"] == "m2m"
), "Only m2m operations are expected to produce dict changes now"
m2m_changes[field] = change
else:
atom_changes[field] = change
msg = []
if atom_changes:
msg.append("<table>")
msg.append(self._format_header("#", "Field", "From", "To"))
for i, (field, change) in enumerate(sorted(atom_changes.items()), 1):
value = [i, self.field_verbose_name(obj, field)] + (
["***", "***"] if field == "password" else change
)
msg.append(self._format_line(*value))
msg.append("</table>")
if m2m_changes:
msg.append("<table>")
msg.append(self._format_header("#", "Relationship", "Action", "Objects"))
for i, (field, change) in enumerate(sorted(m2m_changes.items()), 1):
change_html = format_html_join(
mark_safe("<br>"),
"{}",
[(value,) for value in change["objects"]],
)
msg.append(
format_html(
"<tr><td>{}</td><td>{}</td><td>{}</td><td>{}</td></tr>",
i,
self.field_verbose_name(obj, field),
change["operation"],
change_html,
)
)
msg.append("</table>")
return mark_safe("".join(msg))
@admin.display(description="Correlation ID") @admin.display(description="Correlation ID")
def cid_url(self, obj): def cid_url(self, obj):
@ -127,43 +85,95 @@ class LogEntryAdminMixin:
'<a href="{}" title="{}">{}</a>', url, self.CID_TITLE, cid '<a href="{}" title="{}">{}</a>', url, self.CID_TITLE, cid
) )
def _format_header(self, *labels):
return format_html(
"".join(["<tr>", "<th>{}</th>" * len(labels), "</tr>"]), *labels
)
def _format_line(self, *values):
return format_html(
"".join(["<tr>", "<td>{}</td>" * len(values), "</tr>"]), *values
)
def field_verbose_name(self, obj, field_name: str):
model = obj.content_type.model_class()
if model is None:
return field_name
try:
model_fields = auditlog.get_model_fields(model._meta.model)
mapping_field_name = model_fields["mapping_fields"].get(field_name)
if mapping_field_name:
return mapping_field_name
except KeyError:
# Model definition in auditlog was probably removed
pass
try:
field = model._meta.get_field(field_name)
return pretty_name(getattr(field, "verbose_name", field_name))
except FieldDoesNotExist:
return pretty_name(field_name)
def _add_query_parameter(self, key: str, value: str): def _add_query_parameter(self, key: str, value: str):
full_path = self.request.get_full_path() full_path = self.request.get_full_path()
delimiter = "&" if "?" in full_path else "?" delimiter = "&" if "?" in full_path else "?"
return f"{full_path}{delimiter}{key}={value}" return f"{full_path}{delimiter}{key}={value}"
def field_verbose_name(self, obj, field_name: str):
"""
Use `auditlog.render.get_field_verbose_name` instead.
This method is kept for backward compatibility.
"""
return get_field_verbose_name(obj, field_name)
class LogAccessMixin: class LogAccessMixin:
def render_to_response(self, context, **response_kwargs): def render_to_response(self, context, **response_kwargs):
obj = self.get_object() obj = self.get_object()
accessed.send(obj.__class__, instance=obj) accessed.send(obj.__class__, instance=obj)
return super().render_to_response(context, **response_kwargs) return super().render_to_response(context, **response_kwargs)
class AuditlogHistoryAdminMixin:
"""
Add an audit log history view to a model admin.
"""
auditlog_history_template = "auditlog/object_history.html"
show_auditlog_history_link = False
auditlog_history_per_page = 10
def get_list_display(self, request):
list_display = list(super().get_list_display(request))
if self.show_auditlog_history_link and "auditlog_link" not in list_display:
list_display.append("auditlog_link")
return list_display
def get_urls(self):
opts = self.model._meta
info = opts.app_label, opts.model_name
my_urls = [
path(
"<path:object_id>/auditlog/",
self.admin_site.admin_view(self.auditlog_history_view),
name="%s_%s_auditlog" % info,
)
]
return my_urls + super().get_urls()
def auditlog_history_view(self, request, object_id, extra_context=None):
obj = self.get_object(request, unquote(object_id))
if not self.has_view_permission(request, obj):
raise PermissionDenied
log_entries = (
LogEntry.objects.get_for_object(obj)
.select_related("actor")
.order_by("-timestamp")
)
paginator = self.get_paginator(
request, log_entries, self.auditlog_history_per_page
)
page_number = request.GET.get(PAGE_VAR, 1)
page_obj = paginator.get_page(page_number)
page_range = paginator.get_elided_page_range(page_obj.number)
context = {
**self.admin_site.each_context(request),
"title": _("Audit log: %s") % obj,
"module_name": str(capfirst(self.model._meta.verbose_name_plural)),
"page_range": page_range,
"page_var": PAGE_VAR,
"pagination_required": paginator.count > self.auditlog_history_per_page,
"object": obj,
"opts": self.model._meta,
"log_entries": page_obj,
**(extra_context or {}),
}
return TemplateResponse(request, self.auditlog_history_template, context)
@admin.display(description=_("Audit log"))
def auditlog_link(self, obj):
opts = self.model._meta
url = reverse(
f"admin:{opts.app_label}_{opts.model_name}_auditlog",
args=[obj.pk],
)
return format_html('<a href="{}">{}</a>', url, _("View"))

View file

@ -1,9 +1,10 @@
import ast import ast
import contextlib import contextlib
import json import json
from collections.abc import Callable
from copy import deepcopy from copy import deepcopy
from datetime import timezone from datetime import timezone
from typing import Any, Callable, Union from typing import Any
from dateutil import parser from dateutil import parser
from dateutil.tz import gettz 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.encoding import smart_str
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from auditlog import get_logentry_model
from auditlog.diff import get_mask_function from auditlog.diff import get_mask_function
DEFAULT_OBJECT_REPR = "<error forming object repr>" DEFAULT_OBJECT_REPR = "<error forming object repr>"
@ -303,7 +305,7 @@ class LogEntryManager(models.Manager):
return data 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 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. (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() objects = LogEntryManager()
class Meta: class Meta:
abstract = True
get_latest_by = "timestamp" get_latest_by = "timestamp"
ordering = ["-timestamp"] ordering = ["-timestamp"]
verbose_name = _("log entry") 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. 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 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. :param separator: The string to place between each field.
:return: A readable string of the changes in this log entry. :return: A readable string of the changes in this log entry.
""" """
substrings = [] substrings = []
for field, values in self.changes_dict.items(): for field, value in sorted(self.changes_dict.items()):
substring = "{field_name:s}{colon:s}{old:s}{arrow:s}{new:s}".format( if isinstance(value, (list, tuple)) and len(value) == 2:
field_name=field, # handle regular field change
colon=colon, substring = "{field_name:s}{colon:s}{old:s}{arrow:s}{new:s}".format(
old=values[0], field_name=field,
arrow=arrow, colon=colon,
new=values[1], old=value[0],
) arrow=arrow,
substrings.append(substring) 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) return separator.join(substrings)
@ -455,9 +466,17 @@ class LogEntry(models.Model):
if auditlog.contains(model._meta.model): if auditlog.contains(model._meta.model):
model_fields = auditlog.get_model_fields(model._meta.model) model_fields = auditlog.get_model_fields(model._meta.model)
if settings.AUDITLOG_STORE_JSON_CHANGES:
changes_dict = {}
for field_name, values in self.changes_dict.items():
values_as_strings = [str(v) for v in values]
changes_dict[field_name] = values_as_strings
else:
changes_dict = self.changes_dict
changes_display_dict = {} changes_display_dict = {}
# grab the changes_dict and iterate through # grab the changes_dict and iterate through
for field_name, values in self.changes_dict.items(): for field_name, values in changes_dict.items():
# try to get the field attribute on the model # try to get the field attribute on the model
try: try:
field = model._meta.get_field(field_name) field = model._meta.get_field(field_name)
@ -526,7 +545,7 @@ class LogEntry(models.Model):
return changes_display_dict return changes_display_dict
def _get_changes_display_for_fk_field( 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: ) -> str:
""" """
:return: A string representing a given FK value and the field to which it belongs :return: A string representing a given FK value and the field to which it belongs
@ -545,12 +564,19 @@ class LogEntry(models.Model):
return value return value
# Attempt to return the string representation of the object # Attempt to return the string representation of the object
try: 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. # ObjectDoesNotExist will be raised if the object was deleted.
except ObjectDoesNotExist: except ObjectDoesNotExist:
return f"Deleted '{field.related_model.__name__}' ({value})" return f"Deleted '{field.related_model.__name__}' ({value})"
class LogEntry(AbstractLogEntry):
class Meta(AbstractLogEntry.Meta):
swappable = "AUDITLOG_LOGENTRY_MODEL"
class AuditlogHistoryField(GenericRelation): class AuditlogHistoryField(GenericRelation):
""" """
A subclass of py:class:`django.contrib.contenttypes.fields.GenericRelation` that sets some default A subclass of py:class:`django.contrib.contenttypes.fields.GenericRelation` that sets some default
@ -571,7 +597,7 @@ class AuditlogHistoryField(GenericRelation):
""" """
def __init__(self, pk_indexable=True, delete_related=False, **kwargs): def __init__(self, pk_indexable=True, delete_related=False, **kwargs):
kwargs["to"] = LogEntry kwargs["to"] = get_logentry_model()
if pk_indexable: if pk_indexable:
kwargs["object_id_field"] = "object_id" kwargs["object_id_field"] = "object_id"
@ -614,3 +640,16 @@ def _changes_func() -> Callable[[LogEntry], dict]:
if settings.AUDITLOG_USE_TEXT_CHANGES_IF_JSON_IS_NOT_PRESENT: if settings.AUDITLOG_USE_TEXT_CHANGES_IF_JSON_IS_NOT_PRESENT:
return json_then_text return json_then_text
return default 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 django.conf import settings
from auditlog import get_logentry_model
from auditlog.context import auditlog_disabled from auditlog.context import auditlog_disabled
from auditlog.diff import model_instance_diff 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 from auditlog.signals import post_log, pre_log
@ -38,7 +39,7 @@ def log_create(sender, instance, created, **kwargs):
""" """
if created: if created:
_create_log_entry( _create_log_entry(
action=LogEntry.Action.CREATE, action=get_logentry_model().Action.CREATE,
instance=instance, instance=instance,
sender=sender, sender=sender,
diff_old=None, diff_old=None,
@ -56,9 +57,9 @@ def log_update(sender, instance, **kwargs):
""" """
if not instance._state.adding and instance.pk is not None: if not instance._state.adding and instance.pk is not None:
update_fields = kwargs.get("update_fields", 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( _create_log_entry(
action=LogEntry.Action.UPDATE, action=get_logentry_model().Action.UPDATE,
instance=instance, instance=instance,
sender=sender, sender=sender,
diff_old=old, diff_old=old,
@ -77,7 +78,7 @@ def log_delete(sender, instance, **kwargs):
""" """
if instance.pk is not None: if instance.pk is not None:
_create_log_entry( _create_log_entry(
action=LogEntry.Action.DELETE, action=get_logentry_model().Action.DELETE,
instance=instance, instance=instance,
sender=sender, sender=sender,
diff_old=instance, diff_old=instance,
@ -94,7 +95,7 @@ def log_access(sender, instance, **kwargs):
""" """
if instance.pk is not None: if instance.pk is not None:
_create_log_entry( _create_log_entry(
action=LogEntry.Action.ACCESS, action=get_logentry_model().Action.ACCESS,
instance=instance, instance=instance,
sender=sender, sender=sender,
diff_old=None, diff_old=None,
@ -122,6 +123,7 @@ def _create_log_entry(
if any(item[1] is False for item in pre_log_results): if any(item[1] is False for item in pre_log_results):
return return
LogEntry = get_logentry_model()
error = None error = None
log_entry = 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.""" """Handle m2m_changed and call LogEntry.objects.log_m2m_changes as needed."""
if action not in ["post_add", "post_clear", "post_remove"]: if action not in ["post_add", "post_clear", "post_remove"]:
return return
LogEntry = get_logentry_model()
model_manager = _get_manager_from_settings(kwargs["model"])
if action == "post_clear": if action == "post_clear":
changed_queryset = kwargs["model"]._default_manager.all() changed_queryset = model_manager.all()
else: else:
changed_queryset = kwargs["model"]._default_manager.filter( changed_queryset = model_manager.filter(pk__in=kwargs["pk_set"])
pk__in=kwargs["pk_set"]
)
if action in ["post_add"]: if action in ["post_add"]:
LogEntry.objects.log_m2m_changes( LogEntry.objects.log_m2m_changes(

View file

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

95
auditlog/render.py Normal file
View file

@ -0,0 +1,95 @@
from django.core.exceptions import FieldDoesNotExist
from django.forms.utils import pretty_name
from django.utils.html import format_html, format_html_join
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
def render_logentry_changes_html(log_entry):
changes = log_entry.changes_dict
if not changes:
return ""
atom_changes = {}
m2m_changes = {}
# Separate regular fields from M2M changes
for field, change in changes.items():
if isinstance(change, dict) and change.get("type") == "m2m":
m2m_changes[field] = change
else:
atom_changes[field] = change
html_parts = []
# Render regular field changes
if atom_changes:
html_parts.append(_render_field_changes(log_entry, atom_changes))
# Render M2M relationship changes
if m2m_changes:
html_parts.append(_render_m2m_changes(log_entry, m2m_changes))
return mark_safe("".join(html_parts))
def get_field_verbose_name(log_entry, field_name):
from auditlog.registry import auditlog
model = log_entry.content_type.model_class()
if model is None:
return field_name
# Try to get verbose name from auditlog mapping
try:
if auditlog.contains(model._meta.model):
model_fields = auditlog.get_model_fields(model._meta.model)
mapping_field_name = model_fields["mapping_fields"].get(field_name)
if mapping_field_name:
return mapping_field_name
except KeyError:
# Model definition in auditlog was probably removed
pass
# Fall back to Django field verbose_name
try:
field = model._meta.get_field(field_name)
return pretty_name(getattr(field, "verbose_name", field_name))
except FieldDoesNotExist:
return pretty_name(field_name)
def _render_field_changes(log_entry, atom_changes):
rows = []
rows.append(_format_header("#", _("Field"), _("From"), _("To")))
for i, (field, change) in enumerate(sorted(atom_changes.items()), 1):
field_name = get_field_verbose_name(log_entry, field)
values = ["***", "***"] if field == "password" else change
rows.append(_format_row(i, field_name, *values))
return f"<table>{''.join(rows)}</table>"
def _render_m2m_changes(log_entry, m2m_changes):
rows = []
rows.append(_format_header("#", _("Relationship"), _("Action"), _("Objects")))
for i, (field, change) in enumerate(sorted(m2m_changes.items()), 1):
field_name = get_field_verbose_name(log_entry, field)
objects_html = format_html_join(
mark_safe("<br>"),
"{}",
[(obj,) for obj in change["objects"]],
)
rows.append(_format_row(i, field_name, change["operation"], objects_html))
return f"<table>{''.join(rows)}</table>"
def _format_header(*labels):
return format_html("".join(["<tr>", "<th>{}</th>" * len(labels), "</tr>"]), *labels)
def _format_row(*values):
return format_html("".join(["<tr>", "<td>{}</td>" * len(values), "</tr>"]), *values)

View file

@ -0,0 +1,18 @@
{% load i18n auditlog_tags %}
<div class="auditlog-entry">
<div class="entry-header">
<div class="entry-meta">
<span class="entry-timestamp">{{ entry.timestamp|date:"DATETIME_FORMAT" }}</span>
<span class="entry-user">{% if entry.actor %}{{ entry.actor }}{% else %}{% trans 'system' %}{% endif %}</span>
<span class="entry-action">{{ entry.get_action_display }}</span>
</div>
</div>
<div class="entry-content">
{% if entry.action == entry.Action.DELETE or entry.action == entry.Action.ACCESS %}
<span class="no-changes">{% trans 'No field changes' %}</span>
{% else %}
{{ entry|render_logentry_changes_html|safe }}
{% endif %}
</div>
</div>

View file

@ -0,0 +1,160 @@
{% extends "admin/base_site.html" %}
{% load i18n admin_urls static %}
{% block extrahead %}
{{ block.super }}
<style type="text/css">
.auditlog-entries {
display: flex;
flex-direction: column;
gap: 15px;
max-width: 1200px;
}
.auditlog-entry {
border: 1px solid var(--hairline-color, #e1e1e1);
border-radius: 4px;
}
.entry-header {
padding: 8px 12px;
border-bottom: 1px solid var(--hairline-color, #e1e1e1);
}
.entry-meta {
display: flex;
flex-direction: row;
gap: 16px;
}
.entry-timestamp {
font-weight: 600;
font-size: 0.9em;
}
.entry-user {
font-size: 0.9em;
}
.entry-action {
padding: 1px 6px;
border-radius: 3px;
font-size: 0.8em;
font-weight: 500;
border: 1px solid var(--hairline-color, #e1e1e1);
}
.entry-content {
padding: 12px;
}
.no-changes {
font-style: italic;
opacity: 0.7;
font-size: 0.9em;
}
/* Table styling */
.entry-content table {
width: auto;
min-width: 100%;
border-collapse: collapse;
margin: 6px 0;
font-size: 0.9em;
}
.entry-content table th,
.entry-content table td {
padding: 6px 8px;
text-align: left;
vertical-align: top;
border: 1px solid var(--hairline-color, #e1e1e1);
white-space: nowrap;
}
.entry-content table th {
font-weight: 600;
font-size: 0.85em;
}
.entry-content table td {
max-width: 200px;
word-wrap: break-word;
white-space: normal;
}
.entry-content table + table {
margin-top: 8px;
}
/* Pagination styling */
.pagination {
margin-top: 16px;
text-align: center;
}
.pagination a,
.pagination span {
display: inline-block;
padding: 4px 8px;
margin: 0 2px;
border: 1px solid var(--hairline-color, #e1e1e1);
text-decoration: none;
border-radius: 3px;
}
.pagination .current {
font-weight: 600;
border-width: 2px;
}
.pagination-info {
text-align: center;
margin-top: 8px;
opacity: 0.7;
font-size: 0.9em;
}
/* Responsive */
@media (max-width: 768px) {
.auditlog-entries {
max-width: 100%;
}
.entry-content {
padding: 6px;
}
}
</style>
{% endblock %}
{% block breadcrumbs %}
<div class="breadcrumbs">
<a href="{% url 'admin:index' %}">{% translate 'Home' %}</a>
&rsaquo; <a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ opts.app_config.verbose_name }}</a>
&rsaquo; <a href="{% url opts|admin_urlname:'changelist' %}">{{ module_name }}</a>
&rsaquo; <a href="{% url opts|admin_urlname:'change' object.pk|admin_urlquote %}">{{ object|truncatewords:'18' }}</a>
&rsaquo; {% translate 'Audit log' %}
</div>
{% endblock %}
{% block content %}
<div id="content-main">
<div id="auditlog-history" class="module">
{% if log_entries %}
<div class="auditlog-entries">
{% for entry in log_entries %}
{% include "auditlog/entry_detail.html" with entry=entry %}
{% endfor %}
</div>
{% if pagination_required %}
{% include "auditlog/pagination.html" with page_obj=log_entries page_var=page_var %}
{% endif %}
{% else %}
<p>{% trans 'No log entries found.' %}</p>
{% endif %}
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,16 @@
{% load i18n %}
<div class="pagination">
{% for i in page_obj.paginator.page_range %}
{% if i == page_obj.paginator.ELLIPSIS %}
<span>...</span>
{% elif i == page_obj.number %}
<span class="current">{{ i }}</span>
{% else %}
<a href="?{{ page_var }}={{ i }}">{{ i }}</a>
{% endif %}
{% endfor %}
</div>
<p class="pagination-info">
{{ page_obj.paginator.count }} {% blocktranslate count counter=page_obj.paginator.count %}entry{% plural %}entries{% endblocktranslate %}
</p>

View file

View file

@ -0,0 +1,16 @@
from django import template
from auditlog.render import render_logentry_changes_html as render_changes
register = template.Library()
@register.filter
def render_logentry_changes_html(log_entry):
"""
Format LogEntry changes as HTML.
Usage in template:
{{ log_entry_object|render_logentry_changes_html|safe }}
"""
return render_changes(log_entry)

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

@ -0,0 +1,47 @@
services:
postgres:
container_name: auditlog_postgres
image: postgres:15
restart: "no"
environment:
POSTGRES_DB: auditlog
POSTGRES_USER: ${TEST_DB_USER}
POSTGRES_PASSWORD: ${TEST_DB_PASS}
ports:
- "${TEST_DB_PORT:-5432}:5432"
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: pg_isready -U ${TEST_DB_USER} -d auditlog
interval: 5s
timeout: 3s
retries: 5
mysql:
container_name: auditlog_mysql
platform: linux/x86_64
image: mysql:8.4
restart: "no"
environment:
MYSQL_DATABASE: auditlog
MYSQL_USER: ${TEST_DB_USER}
MYSQL_PASSWORD: ${TEST_DB_PASS}
MYSQL_ROOT_PASSWORD: ${TEST_DB_PASS}
ports:
- "${TEST_DB_PORT:-3306}:3306"
expose:
- '${TEST_DB_PORT:-3306}'
volumes:
- mysql-data:/var/lib/mysql
- ./docker/db/init-mysql.sh:/docker-entrypoint-initdb.d/init.sh
healthcheck:
test: mysqladmin ping -h 127.0.0.1 -u ${TEST_DB_USER} --password=${TEST_DB_PASS}
interval: 5s
timeout: 3s
retries: 3
volumes:
postgres-data:
driver: local
mysql-data:
driver: local

View file

@ -0,0 +1,6 @@
#!/usr/bin/env bash
set -e
mysql -u root -p"$MYSQL_ROOT_PASSWORD" <<-EOSQL
GRANT ALL PRIVILEGES ON test_auditlog.* to '$MYSQL_USER';
EOSQL

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

@ -1,6 +1,6 @@
import uuid import uuid
from django.contrib.postgres.fields import ArrayField from django.conf import settings
from django.core.serializers.json import DjangoJSONEncoder from django.core.serializers.json import DjangoJSONEncoder
from django.db import models from django.db import models
@ -311,26 +311,36 @@ class CharfieldTextfieldModel(models.Model):
history = AuditlogHistoryField(delete_related=True) history = AuditlogHistoryField(delete_related=True)
class PostgresArrayFieldModel(models.Model): # Only define PostgreSQL-specific models when ArrayField is available
""" if settings.TEST_DB_BACKEND == "postgresql":
Test auditlog with Postgres's ArrayField from django.contrib.postgres.fields import ArrayField
"""
RED = "r" class PostgresArrayFieldModel(models.Model):
YELLOW = "y" """
GREEN = "g" Test auditlog with Postgres's ArrayField
"""
STATUS_CHOICES = ( RED = "r"
(RED, "Red"), YELLOW = "y"
(YELLOW, "Yellow"), GREEN = "g"
(GREEN, "Green"),
)
arrayfield = ArrayField( STATUS_CHOICES = (
models.CharField(max_length=1, choices=STATUS_CHOICES), size=3 (RED, "Red"),
) (YELLOW, "Yellow"),
(GREEN, "Green"),
)
history = AuditlogHistoryField(delete_related=True) arrayfield = ArrayField(
models.CharField(max_length=1, choices=STATUS_CHOICES), size=3
)
history = AuditlogHistoryField(delete_related=True)
else:
class PostgresArrayFieldModel(models.Model):
class Meta:
managed = False
class NoDeleteHistoryModel(models.Model): class NoDeleteHistoryModel(models.Model):
@ -420,6 +430,40 @@ class SwappedManagerModel(models.Model):
objects = SecretManager() 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): class AutoManyRelatedModel(models.Model):
related = models.ManyToManyField(SimpleModel) related = models.ManyToManyField(SimpleModel)
@ -432,23 +476,34 @@ class CustomMaskModel(models.Model):
history = AuditlogHistoryField(delete_related=True) history = AuditlogHistoryField(delete_related=True)
class NullableFieldModel(models.Model):
time = models.TimeField(null=True, blank=True)
optional_text = models.CharField(max_length=100, null=True, blank=True)
history = AuditlogHistoryField(delete_related=True)
auditlog.register(AltPrimaryKeyModel) auditlog.register(AltPrimaryKeyModel)
auditlog.register(UUIDPrimaryKeyModel) auditlog.register(UUIDPrimaryKeyModel)
auditlog.register(ModelPrimaryKeyModel) auditlog.register(ModelPrimaryKeyModel)
auditlog.register(ProxyModel) auditlog.register(ProxyModel)
auditlog.register(RelatedModelParent)
auditlog.register(RelatedModel) auditlog.register(RelatedModel)
auditlog.register(ManyRelatedModel) auditlog.register(ManyRelatedModel)
auditlog.register(ManyRelatedModel.recursive.through) auditlog.register(ManyRelatedModel.recursive.through)
m2m_only_auditlog.register(ManyRelatedModel, m2m_fields={"related"}) m2m_only_auditlog.register(ManyRelatedModel, m2m_fields={"related"})
m2m_only_auditlog.register(ModelForReusableThroughModel, m2m_fields={"related"}) m2m_only_auditlog.register(ModelForReusableThroughModel, m2m_fields={"related"})
m2m_only_auditlog.register(OtherModelForReusableThroughModel, 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(SimpleExcludeModel, exclude_fields=["text"])
auditlog.register(SimpleMappingModel, mapping_fields={"sku": "Product No."}) auditlog.register(SimpleMappingModel, mapping_fields={"sku": "Product No."})
auditlog.register(AdditionalDataIncludedModel) auditlog.register(AdditionalDataIncludedModel)
auditlog.register(DateTimeFieldModel) auditlog.register(DateTimeFieldModel)
auditlog.register(ChoicesFieldModel) auditlog.register(ChoicesFieldModel)
auditlog.register(CharfieldTextfieldModel) auditlog.register(CharfieldTextfieldModel)
auditlog.register(PostgresArrayFieldModel) if settings.TEST_DB_BACKEND == "postgresql":
auditlog.register(PostgresArrayFieldModel)
auditlog.register(NoDeleteHistoryModel) auditlog.register(NoDeleteHistoryModel)
auditlog.register(JSONModel) auditlog.register(JSONModel)
auditlog.register(NullableJSONModel) auditlog.register(NullableJSONModel)
@ -474,3 +529,4 @@ auditlog.register(
mask_fields=["credit_card"], mask_fields=["credit_card"],
mask_callable="auditlog_tests.test_app.mask.custom_mask_str", mask_callable="auditlog_tests.test_app.mask.custom_mask_str",
) )
auditlog.register(NullableFieldModel)

View file

@ -6,9 +6,13 @@ from unittest import mock
import freezegun import freezegun
from django.core.management import call_command from django.core.management import call_command
from django.db import connection
from django.test import TestCase, TransactionTestCase from django.test import TestCase, TransactionTestCase
from django.test.utils import skipIf
from test_app.models import SimpleModel from test_app.models import SimpleModel
from auditlog.management.commands.auditlogflush import TruncateQuery
class AuditlogFlushTest(TestCase): class AuditlogFlushTest(TestCase):
def setUp(self): def setUp(self):
@ -117,6 +121,9 @@ class AuditlogFlushWithTruncateTest(TransactionTestCase):
self.mock_input = input_patcher.start() self.mock_input = input_patcher.start()
self.addCleanup(input_patcher.stop) self.addCleanup(input_patcher.stop)
def _fixture_teardown(self):
call_command("flush", verbosity=0, interactive=False, allow_cascade=True)
def make_object(self): def make_object(self):
return SimpleModel.objects.create(text="I am a simple model.") return SimpleModel.objects.create(text="I am a simple model.")
@ -139,6 +146,10 @@ class AuditlogFlushWithTruncateTest(TransactionTestCase):
) )
self.assertEqual(err, "", msg="No stderr") self.assertEqual(err, "", msg="No stderr")
@skipIf(
not TruncateQuery.support_truncate_statement(connection.vendor),
"Database does not support TRUNCATE",
)
def test_flush_with_truncate_and_yes(self): def test_flush_with_truncate_and_yes(self):
obj = self.make_object() obj = self.make_object()
self.assertEqual(obj.history.count(), 1, msg="There is one log entry.") self.assertEqual(obj.history.count(), 1, msg="There is one log entry.")
@ -152,6 +163,10 @@ class AuditlogFlushWithTruncateTest(TransactionTestCase):
) )
self.assertEqual(err, "", msg="No stderr") self.assertEqual(err, "", msg="No stderr")
@skipIf(
not TruncateQuery.support_truncate_statement(connection.vendor),
"Database does not support TRUNCATE",
)
def test_flush_with_truncate_with_input_yes(self): def test_flush_with_truncate_with_input_yes(self):
obj = self.make_object() obj = self.make_object()
self.assertEqual(obj.history.count(), 1, msg="There is one log entry.") self.assertEqual(obj.history.count(), 1, msg="There is one log entry.")

View file

@ -0,0 +1,51 @@
"""
PostgreSQL-specific tests for django-auditlog.
"""
from unittest import skipIf
from django.conf import settings
from django.test import TestCase
from test_app.models import PostgresArrayFieldModel
@skipIf(settings.TEST_DB_BACKEND != "postgresql", "PostgreSQL-specific test")
class PostgresArrayFieldModelTest(TestCase):
databases = "__all__"
def setUp(self):
self.obj = PostgresArrayFieldModel.objects.create(
arrayfield=[PostgresArrayFieldModel.RED, PostgresArrayFieldModel.GREEN],
)
@property
def latest_array_change(self):
return self.obj.history.latest().changes_display_dict["arrayfield"][1]
def test_changes_display_dict_arrayfield(self):
self.assertEqual(
self.latest_array_change,
"Red, Green",
msg="The human readable text for the two choices, 'Red, Green' is displayed.",
)
self.obj.arrayfield = [PostgresArrayFieldModel.GREEN]
self.obj.save()
self.assertEqual(
self.latest_array_change,
"Green",
msg="The human readable text 'Green' is displayed.",
)
self.obj.arrayfield = []
self.obj.save()
self.assertEqual(
self.latest_array_change,
"",
msg="The human readable text '' is displayed.",
)
self.obj.arrayfield = [PostgresArrayFieldModel.GREEN]
self.obj.save()
self.assertEqual(
self.latest_array_change,
"Green",
msg="The human readable text 'Green' is displayed.",
)

View file

@ -0,0 +1,167 @@
from django.test import TestCase
from test_app.models import SimpleModel
from auditlog import get_logentry_model
from auditlog.templatetags.auditlog_tags import render_logentry_changes_html
LogEntry = get_logentry_model()
class RenderChangesTest(TestCase):
def _create_log_entry(self, action, changes):
return LogEntry.objects.log_create(
SimpleModel.objects.create(),
action=action,
changes=changes,
)
def test_render_changes_empty(self):
log_entry = self._create_log_entry(LogEntry.Action.CREATE, {})
result = render_logentry_changes_html(log_entry)
self.assertEqual(result, "")
def test_render_changes_simple_field(self):
changes = {"text": ["old text", "new text"]}
log_entry = self._create_log_entry(LogEntry.Action.UPDATE, changes)
result = render_logentry_changes_html(log_entry)
self.assertIn("<table>", result)
self.assertIn("<th>#</th>", result)
self.assertIn("<th>Field</th>", result)
self.assertIn("<th>From</th>", result)
self.assertIn("<th>To</th>", result)
self.assertIn("old text", result)
self.assertIn("new text", result)
self.assertIsInstance(result, str)
def test_render_changes_password_field(self):
changes = {"password": ["oldpass", "newpass"]}
log_entry = self._create_log_entry(LogEntry.Action.UPDATE, changes)
result = render_logentry_changes_html(log_entry)
self.assertIn("***", result)
self.assertNotIn("oldpass", result)
self.assertNotIn("newpass", result)
def test_render_changes_m2m_field(self):
changes = {
"related_objects": {
"type": "m2m",
"operation": "add",
"objects": ["obj1", "obj2", "obj3"],
}
}
log_entry = self._create_log_entry(LogEntry.Action.UPDATE, changes)
result = render_logentry_changes_html(log_entry)
self.assertIn("<table>", result)
self.assertIn("<th>#</th>", result)
self.assertIn("<th>Relationship</th>", result)
self.assertIn("<th>Action</th>", result)
self.assertIn("<th>Objects</th>", result)
self.assertIn("add", result)
self.assertIn("obj1", result)
self.assertIn("obj2", result)
self.assertIn("obj3", result)
def test_render_changes_mixed_fields(self):
changes = {
"text": ["old text", "new text"],
"related_objects": {
"type": "m2m",
"operation": "remove",
"objects": ["obj1"],
},
}
log_entry = self._create_log_entry(LogEntry.Action.UPDATE, changes)
result = render_logentry_changes_html(log_entry)
tables = result.count("<table>")
self.assertEqual(tables, 2)
self.assertIn("old text", result)
self.assertIn("new text", result)
self.assertIn("remove", result)
self.assertIn("obj1", result)
def test_render_changes_field_verbose_name(self):
changes = {"text": ["old", "new"]}
log_entry = self._create_log_entry(LogEntry.Action.UPDATE, changes)
result = render_logentry_changes_html(log_entry)
self.assertIn("Text", result)
def test_render_changes_with_none_values(self):
changes = {"text": [None, "new text"], "boolean": [True, None]}
log_entry = self._create_log_entry(LogEntry.Action.UPDATE, changes)
result = render_logentry_changes_html(log_entry)
self.assertIn("None", result)
self.assertIn("new text", result)
self.assertIn("True", result)
def test_render_changes_sorted_fields(self):
changes = {
"z_field": ["old", "new"],
"a_field": ["old", "new"],
"m_field": ["old", "new"],
}
log_entry = self._create_log_entry(LogEntry.Action.UPDATE, changes)
result = render_logentry_changes_html(log_entry)
a_index = result.find("A field")
m_index = result.find("M field")
z_index = result.find("Z field")
self.assertLess(a_index, m_index)
self.assertLess(m_index, z_index)
def test_render_changes_m2m_sorted_fields(self):
changes = {
"z_related": {"type": "m2m", "operation": "add", "objects": ["obj1"]},
"a_related": {"type": "m2m", "operation": "remove", "objects": ["obj2"]},
}
log_entry = self._create_log_entry(LogEntry.Action.UPDATE, changes)
result = render_logentry_changes_html(log_entry)
a_index = result.find("A related")
z_index = result.find("Z related")
self.assertLess(a_index, z_index)
def test_render_changes_create_action(self):
changes = {
"text": [None, "new value"],
"boolean": [None, True],
}
log_entry = self._create_log_entry(LogEntry.Action.CREATE, changes)
result = render_logentry_changes_html(log_entry)
self.assertIn("<table>", result)
self.assertIn("new value", result)
self.assertIn("True", result)
def test_render_changes_delete_action(self):
changes = {
"text": ["old value", None],
"boolean": [True, None],
}
log_entry = self._create_log_entry(LogEntry.Action.DELETE, changes)
result = render_logentry_changes_html(log_entry)
self.assertIn("<table>", result)
self.assertIn("old value", result)
self.assertIn("True", result)
self.assertIn("None", result)

View file

@ -8,6 +8,8 @@ DEBUG = True
SECRET_KEY = "test" SECRET_KEY = "test"
TEST_DB_BACKEND = os.getenv("TEST_DB_BACKEND", "sqlite3")
INSTALLED_APPS = [ INSTALLED_APPS = [
"django.contrib.auth", "django.contrib.auth",
"django.contrib.contenttypes", "django.contrib.contenttypes",
@ -16,30 +18,71 @@ INSTALLED_APPS = [
"django.contrib.admin", "django.contrib.admin",
"django.contrib.staticfiles", "django.contrib.staticfiles",
"django.contrib.postgres", "django.contrib.postgres",
"custom_logentry_app",
"auditlog", "auditlog",
"test_app", "test_app",
] ]
MIDDLEWARE = [ MIDDLEWARE = [
"django.middleware.common.CommonMiddleware", "django.middleware.common.CommonMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware", "django.contrib.sessions.middleware.SessionMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware", "django.contrib.messages.middleware.MessageMiddleware",
"auditlog.middleware.AuditlogMiddleware",
] ]
DATABASES = { if os.environ.get("AUDITLOG_LOGENTRY_MODEL", None):
"default": { MIDDLEWARE = MIDDLEWARE + ["auditlog.middleware.AuditlogMiddleware"]
"ENGINE": "django.db.backends.postgresql", else:
"NAME": os.getenv( MIDDLEWARE = MIDDLEWARE + ["middleware.CustomAuditlogMiddleware"]
"TEST_DB_NAME", "auditlog" + os.environ.get("TOX_PARALLEL_ENV", "")
),
"USER": os.getenv("TEST_DB_USER", "postgres"), if TEST_DB_BACKEND == "postgresql":
"PASSWORD": os.getenv("TEST_DB_PASS", ""), DATABASES = {
"HOST": os.getenv("TEST_DB_HOST", "127.0.0.1"), "default": {
"PORT": os.getenv("TEST_DB_PORT", "5432"), "ENGINE": "django.db.backends.postgresql",
"NAME": os.getenv(
"TEST_DB_NAME", "auditlog" + os.environ.get("TOX_PARALLEL_ENV", "")
),
"USER": os.getenv("TEST_DB_USER", "postgres"),
"PASSWORD": os.getenv("TEST_DB_PASS", ""),
"HOST": os.getenv("TEST_DB_HOST", "127.0.0.1"),
"PORT": os.getenv("TEST_DB_PORT", "5432"),
}
} }
} elif TEST_DB_BACKEND == "mysql":
DATABASES = {
"default": {
"ENGINE": "django.db.backends.mysql",
"NAME": os.getenv(
"TEST_DB_NAME", "auditlog" + os.environ.get("TOX_PARALLEL_ENV", "")
),
"USER": os.getenv("TEST_DB_USER", "root"),
"PASSWORD": os.getenv("TEST_DB_PASS", ""),
"HOST": os.getenv("TEST_DB_HOST", "127.0.0.1"),
"PORT": os.getenv("TEST_DB_PORT", "3306"),
"OPTIONS": {
"charset": "utf8mb4",
"init_command": "SET sql_mode='STRICT_TRANS_TABLES'",
},
}
}
elif TEST_DB_BACKEND == "sqlite3":
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": os.getenv(
"TEST_DB_NAME",
(
":memory:"
if os.getenv("TOX_PARALLEL_ENV")
else "test_auditlog.sqlite3"
),
),
}
}
else:
raise ValueError(f"Unsupported database backend: {TEST_DB_BACKEND}")
TEMPLATES = [ TEMPLATES = [
{ {
@ -63,3 +106,5 @@ ROOT_URLCONF = "test_app.urls"
USE_TZ = True USE_TZ = True
DEFAULT_AUTO_FIELD = "django.db.models.AutoField" DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
AUDITLOG_LOGENTRY_MODEL = os.environ.get("AUDITLOG_LOGENTRY_MODEL", "auditlog.LogEntry")

View file

@ -2,11 +2,15 @@ import json
from io import StringIO from io import StringIO
from unittest.mock import patch from unittest.mock import patch
from django.conf import settings
from django.core.management import CommandError, call_command from django.core.management import CommandError, call_command
from django.test import TestCase, override_settings from django.test import TestCase, override_settings
from django.test.utils import skipIf
from test_app.models import SimpleModel from test_app.models import SimpleModel
from auditlog.models import LogEntry from auditlog import get_logentry_model
LogEntry = get_logentry_model()
class TwoStepMigrationTest(TestCase): class TwoStepMigrationTest(TestCase):
@ -117,13 +121,17 @@ class AuditlogMigrateJsonTest(TestCase):
self.make_logentry() self.make_logentry()
# Act # 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") outbuf, errbuf = self.call_command("-b=1")
call_count = bulk_update.call_count call_count = bulk_update.call_count
# Assert # Assert
self.assertEqual(call_count, 2) self.assertEqual(call_count, 2)
@skipIf(settings.TEST_DB_BACKEND != "postgresql", "PostgreSQL-specific test")
def test_native_postgres(self): def test_native_postgres(self):
# Arrange # Arrange
log_entry = self.make_logentry() log_entry = self.make_logentry()
@ -136,6 +144,7 @@ class AuditlogMigrateJsonTest(TestCase):
self.assertEqual(errbuf, "") self.assertEqual(errbuf, "")
self.assertIsNotNone(log_entry.changes) self.assertIsNotNone(log_entry.changes)
@skipIf(settings.TEST_DB_BACKEND != "postgresql", "PostgreSQL-specific test")
def test_native_postgres_changes_not_overwritten(self): def test_native_postgres_changes_not_overwritten(self):
# Arrange # Arrange
log_entry = self.make_logentry() log_entry = self.make_logentry()

View file

@ -1,9 +1,11 @@
from django.test import TestCase, override_settings from django.test import TestCase, override_settings
from test_app.models import JSONModel, RelatedModel, SimpleModel 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 from auditlog.registry import AuditlogModelRegistry
LogEntry = get_logentry_model()
class JSONForChangesTest(TestCase): class JSONForChangesTest(TestCase):
@ -147,3 +149,50 @@ class JSONForChangesTest(TestCase):
all(v[1] is None for k, v in changes_dict.items()), all(v[1] is None for k, v in changes_dict.items()),
'all values in the changes dict should None, not "None"', 'all values in the changes dict should None, not "None"',
) )
@override_settings(AUDITLOG_STORE_JSON_CHANGES=False)
def test_nullable_field_with_none_not_logged(self):
self.test_auditlog.register_from_settings()
obj = NullableFieldModel.objects.create(time=None, optional_text=None)
changes_dict = obj.history.latest().changes_dict
# None → None should NOT be logged as a change
self.assertNotIn("time", changes_dict)
self.assertNotIn("optional_text", changes_dict)
@override_settings(AUDITLOG_STORE_JSON_CHANGES=False)
def test_nullable_field_with_value_logged(self):
self.test_auditlog.register_from_settings()
obj = NullableFieldModel.objects.create(optional_text="something")
changes_dict = obj.history.latest().changes_dict
# None → "something" should be logged
self.assertIn("optional_text", changes_dict)
self.assertEqual(changes_dict["optional_text"], ["None", "something"])
@override_settings(AUDITLOG_STORE_JSON_CHANGES=True)
def test_nullable_field_with_none_not_logged_json_mode(self):
self.test_auditlog.register_from_settings()
obj = NullableFieldModel.objects.create(time=None, optional_text=None)
changes_dict = obj.history.latest().changes_dict
# None → None should NOT be logged
self.assertNotIn("time", changes_dict)
self.assertNotIn("optional_text", changes_dict)
@override_settings(AUDITLOG_STORE_JSON_CHANGES=False)
def test_nullable_field_update_none_to_value(self):
self.test_auditlog.register_from_settings()
obj = NullableFieldModel.objects.create(optional_text=None)
obj.optional_text = "updated"
obj.save()
changes_dict = obj.history.latest().changes_dict
# None → "updated" should be logged
self.assertIn("optional_text", changes_dict)
self.assertEqual(changes_dict["optional_text"], ["None", "updated"])

175
auditlog_tests/test_view.py Normal file
View file

@ -0,0 +1,175 @@
from unittest.mock import patch
from django.contrib import admin
from django.contrib.admin.sites import AdminSite
from django.contrib.auth import get_user_model
from django.test import RequestFactory, TestCase
from test_app.models import SimpleModel
from auditlog.mixins import AuditlogHistoryAdminMixin
class TestModelAdmin(AuditlogHistoryAdminMixin, admin.ModelAdmin):
model = SimpleModel
auditlog_history_per_page = 5
class TestAuditlogHistoryAdminMixin(TestCase):
def setUp(self):
self.user = get_user_model().objects.create_user(
username="test_admin", is_staff=True, is_superuser=True, is_active=True
)
self.site = AdminSite()
self.admin = TestModelAdmin(SimpleModel, self.site)
self.obj = SimpleModel.objects.create(text="Test object")
def test_auditlog_history_view_requires_permission(self):
request = RequestFactory().get("/")
request.user = get_user_model().objects.create_user(
username="non_staff_user", password="testpass"
)
with self.assertRaises(Exception):
self.admin.auditlog_history_view(request, str(self.obj.pk))
def test_auditlog_history_view_with_permission(self):
request = RequestFactory().get("/")
request.user = self.user
response = self.admin.auditlog_history_view(request, str(self.obj.pk))
self.assertEqual(response.status_code, 200)
self.assertIn("log_entries", response.context_data)
self.assertIn("object", response.context_data)
self.assertEqual(response.context_data["object"], self.obj)
def test_auditlog_history_view_pagination(self):
"""Test that pagination works correctly."""
for i in range(10):
self.obj.text = f"Updated text {i}"
self.obj.save()
request = RequestFactory().get("/")
request.user = self.user
response = self.admin.auditlog_history_view(request, str(self.obj.pk))
self.assertTrue(response.context_data["pagination_required"])
self.assertEqual(len(response.context_data["log_entries"]), 5)
def test_auditlog_history_view_page_parameter(self):
# Create more log entries by updating the object
for i in range(10):
self.obj.text = f"Updated text {i}"
self.obj.save()
request = RequestFactory().get("/?p=2")
request.user = self.user
response = self.admin.auditlog_history_view(request, str(self.obj.pk))
# Should be on page 2
self.assertEqual(response.context_data["log_entries"].number, 2)
def test_auditlog_history_view_context_data(self):
request = RequestFactory().get("/")
request.user = self.user
response = self.admin.auditlog_history_view(request, str(self.obj.pk))
context = response.context_data
required_keys = [
"title",
"module_name",
"page_range",
"page_var",
"pagination_required",
"object",
"opts",
"log_entries",
]
for key in required_keys:
self.assertIn(key, context)
self.assertIn(str(self.obj), context["title"])
self.assertEqual(context["object"], self.obj)
self.assertEqual(context["opts"], self.obj._meta)
def test_auditlog_history_view_extra_context(self):
request = RequestFactory().get("/")
request.user = self.user
extra_context = {"extra_key": "extra_value"}
response = self.admin.auditlog_history_view(
request, str(self.obj.pk), extra_context
)
self.assertIn("extra_key", response.context_data)
self.assertEqual(response.context_data["extra_key"], "extra_value")
def test_auditlog_history_view_template(self):
request = RequestFactory().get("/")
request.user = self.user
response = self.admin.auditlog_history_view(request, str(self.obj.pk))
self.assertEqual(response.template_name, self.admin.auditlog_history_template)
def test_auditlog_history_view_log_entries_ordering(self):
self.obj.text = "First update"
self.obj.save()
self.obj.text = "Second update"
self.obj.save()
request = RequestFactory().get("/")
request.user = self.user
response = self.admin.auditlog_history_view(request, str(self.obj.pk))
log_entries = list(response.context_data["log_entries"])
self.assertGreaterEqual(log_entries[0].timestamp, log_entries[1].timestamp)
def test_get_list_display_with_auditlog_link(self):
self.admin.show_auditlog_history_link = True
list_display = self.admin.get_list_display(RequestFactory().get("/"))
self.assertIn("auditlog_link", list_display)
self.admin.show_auditlog_history_link = False
list_display = self.admin.get_list_display(RequestFactory().get("/"))
self.assertNotIn("auditlog_link", list_display)
def test_get_urls_includes_auditlog_url(self):
urls = self.admin.get_urls()
self.assertGreater(len(urls), 0)
url_names = [
url.name for url in urls if hasattr(url, "name") and url.name is not None
]
auditlog_urls = [name for name in url_names if "auditlog" in name]
self.assertGreater(len(auditlog_urls), 0)
@patch("auditlog.mixins.reverse")
def test_auditlog_link(self, mock_reverse):
"""Test that auditlog_link method returns correct HTML link."""
# Mock the reverse function to return a test URL
expected_url = f"/admin/test_app/simplemodel/{self.obj.pk}/auditlog/"
mock_reverse.return_value = expected_url
link_html = self.admin.auditlog_link(self.obj)
self.assertIsInstance(link_html, str)
self.assertIn("<a href=", link_html)
self.assertIn("View</a>", link_html)
self.assertIn(expected_url, link_html)
opts = self.obj._meta
expected_url_name = f"admin:{opts.app_label}_{opts.model_name}_auditlog"
mock_reverse.assert_called_once_with(expected_url_name, args=[self.obj.pk])

View file

@ -4,11 +4,12 @@ import json
import random import random
import warnings import warnings
from datetime import timezone from datetime import timezone
from unittest import mock from unittest import mock, skipIf
from unittest.mock import patch from unittest.mock import patch
import freezegun import freezegun
from dateutil.tz import gettz from dateutil.tz import gettz
from django import VERSION as DJANGO_VERSION
from django.apps import apps from django.apps import apps
from django.conf import settings from django.conf import settings
from django.contrib.admin.sites import AdminSite from django.contrib.admin.sites import AdminSite
@ -16,6 +17,8 @@ from django.contrib.auth import get_user_model
from django.contrib.auth.models import AnonymousUser, User from django.contrib.auth.models import AnonymousUser, User
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core import management 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 import models
from django.db.models import JSONField, Value from django.db.models import JSONField, Value
from django.db.models.functions import Now from django.db.models.functions import Now
@ -42,10 +45,12 @@ from test_app.models import (
ModelPrimaryKeyModel, ModelPrimaryKeyModel,
NoDeleteHistoryModel, NoDeleteHistoryModel,
NullableJSONModel, NullableJSONModel,
PostgresArrayFieldModel,
ProxyModel, ProxyModel,
RelatedModel, RelatedModel,
RelatedModelParent,
ReusableThroughRelatedModel, ReusableThroughRelatedModel,
SecretM2MModel,
SecretRelatedModel,
SerializeNaturalKeyRelatedModel, SerializeNaturalKeyRelatedModel,
SerializeOnlySomeOfThisModel, SerializeOnlySomeOfThisModel,
SerializePrimaryKeyRelatedModel, SerializePrimaryKeyRelatedModel,
@ -60,15 +65,18 @@ from test_app.models import (
UUIDPrimaryKeyModel, UUIDPrimaryKeyModel,
) )
from auditlog import get_logentry_model
from auditlog.admin import LogEntryAdmin from auditlog.admin import LogEntryAdmin
from auditlog.cid import get_cid 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.diff import mask_str, model_instance_diff
from auditlog.middleware import AuditlogMiddleware 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.registry import AuditlogModelRegistry, AuditLogRegistrationError, auditlog
from auditlog.signals import post_log, pre_log from auditlog.signals import post_log, pre_log
LogEntry = get_logentry_model()
class SimpleModelTest(TestCase): class SimpleModelTest(TestCase):
def setUp(self): def setUp(self):
@ -123,6 +131,11 @@ class SimpleModelTest(TestCase):
{"boolean": ["False", "True"]}, {"boolean": ["False", "True"]},
msg="The change is correctly logged", 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): def test_update_specific_field_supplied_via_save_method(self):
obj = self.obj obj = self.obj
@ -141,6 +154,11 @@ class SimpleModelTest(TestCase):
"when using the `update_fields`." "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): def test_django_update_fields_edge_cases(self):
""" """
@ -171,6 +189,11 @@ class SimpleModelTest(TestCase):
{"boolean": ["False", "True"], "integer": ["None", "1"]}, {"boolean": ["False", "True"], "integer": ["None", "1"]},
msg="The 2 fields changed are correctly logged", 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): def test_delete(self):
"""Deletion is logged correctly.""" """Deletion is logged correctly."""
@ -258,7 +281,7 @@ class NoActorMixin:
self.assertIsNone(log_entry.actor) self.assertIsNone(log_entry.actor)
class WithActorMixin: class WithActorMixinBase:
sequence = itertools.count() sequence = itertools.count()
def setUp(self): def setUp(self):
@ -277,10 +300,6 @@ class WithActorMixin:
self.assertIsNotNone(auditlog_entries, msg="All auditlog entries are deleted.") self.assertIsNotNone(auditlog_entries, msg="All auditlog entries are deleted.")
super().tearDown() super().tearDown()
def make_object(self):
with set_actor(self.user):
return super().make_object()
def check_create_log_entry(self, obj, log_entry): def check_create_log_entry(self, obj, log_entry):
super().check_create_log_entry(obj, log_entry) super().check_create_log_entry(obj, log_entry)
self.assertEqual(log_entry.actor, self.user) self.assertEqual(log_entry.actor, self.user)
@ -305,6 +324,12 @@ class WithActorMixin:
self.assertEqual(log_entry.actor_email, self.user.email) 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): class AltPrimaryKeyModelBase(SimpleModelTest):
def make_object(self): def make_object(self):
return AltPrimaryKeyModel.objects.create( return AltPrimaryKeyModel.objects.create(
@ -369,6 +394,10 @@ class ModelPrimaryKeyModelWithActorTest(WithActorMixin, ModelPrimaryKeyModelBase
# Must inherit from TransactionTestCase to use self.assertNumQueries. # Must inherit from TransactionTestCase to use self.assertNumQueries.
class ModelPrimaryKeyTest(TransactionTestCase): class ModelPrimaryKeyTest(TransactionTestCase):
def _fixture_teardown(self):
call_command("flush", verbosity=0, interactive=False, allow_cascade=True)
def test_get_pk_value(self): def test_get_pk_value(self):
""" """
Test that the primary key can be retrieved without additional database queries. 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): def test_adding_existing_related_obj(self):
self.obj.related.add(self.related) self.obj.related.add(self.related)
log_entry = self.obj.history.first() log_entry = self.obj.history.first()
@ -711,6 +747,11 @@ class SimpleIncludeModelTest(TestCase):
{"label": ["Initial label", "New label"]}, {"label": ["Initial label", "New label"]},
msg="Only the label was logged, regardless of multiple entries in `update_fields`", 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): def test_register_include_fields(self):
sim = SimpleIncludeModel(label="Include model", text="Looong text") sim = SimpleIncludeModel(label="Include model", text="Looong text")
@ -798,6 +839,57 @@ class SimpleMappingModelTest(TestCase):
), ),
) )
@override_settings(AUDITLOG_STORE_JSON_CHANGES=True)
def test_changes_display_dict_with_json_changes_and_simplemodel(self):
sm = SimpleModel(integer=37, text="my simple model instance")
sm.save()
self.assertEqual(
sm.history.latest().changes_display_dict["integer"][1],
"37",
)
self.assertEqual(
sm.history.latest().changes_display_dict["text"][1],
"my simple model instance",
)
@override_settings(AUDITLOG_STORE_JSON_CHANGES=True)
def test_register_mapping_fields_with_json_changes(self):
smm = SimpleMappingModel(
sku="ASD301301A6", vtxt="2.1.5", not_mapped="Not mapped"
)
smm.save()
self.assertEqual(
smm.history.latest().changes_dict["sku"][1],
"ASD301301A6",
msg="The diff function retains 'sku' and can be retrieved.",
)
self.assertEqual(
smm.history.latest().changes_dict["not_mapped"][1],
"Not mapped",
msg="The diff function does not map 'not_mapped' and can be retrieved.",
)
self.assertEqual(
smm.history.latest().changes_display_dict["Product No."][1],
"ASD301301A6",
msg="The diff function maps 'sku' as 'Product No.' and can be retrieved.",
)
self.assertEqual(
smm.history.latest().changes_display_dict["Version"][1],
"2.1.5",
msg=(
"The diff function maps 'vtxt' as 'Version' through verbose_name"
" setting on the model field and can be retrieved."
),
)
self.assertEqual(
smm.history.latest().changes_display_dict["not mapped"][1],
"Not mapped",
msg=(
"The diff function uses the django default verbose name for 'not_mapped'"
" and can be retrieved."
),
)
class SimpleMaskedFieldsModelTest(TestCase): class SimpleMaskedFieldsModelTest(TestCase):
"""Log masked changes for fields in mask_fields""" """Log masked changes for fields in mask_fields"""
@ -1179,15 +1271,30 @@ class DateTimeFieldModelTest(TestCase):
dtm.naive_dt = Now() dtm.naive_dt = Now()
self.assertEqual(dtm.naive_dt, Now()) self.assertEqual(dtm.naive_dt, Now())
dtm.save() dtm.save()
self.assertEqual(dtm.naive_dt, Now())
# Django 6.0+ evaluates expressions during save (django ticket #27222)
if DJANGO_VERSION >= (6, 0, 0):
with self.subTest("After save Django 6.0+"):
self.assertIsInstance(dtm.naive_dt, datetime.datetime)
else:
with self.subTest("After save Django < 6.0"):
self.assertEqual(dtm.naive_dt, Now())
def test_json_field_value_none(self): def test_json_field_value_none(self):
json_model = NullableJSONModel(json=Value(None, JSONField())) json_model = NullableJSONModel(json=Value(None, JSONField()))
json_model.save() json_model.save()
self.assertEqual(json_model.history.count(), 1) self.assertEqual(json_model.history.count(), 1)
self.assertEqual( changes_dict = json_model.history.latest().changes_dict
json_model.history.latest().changes_dict["json"][1], "Value(None)"
) # Django 6.0+ evaluates expressions during save (django ticket #27222)
if DJANGO_VERSION >= (6, 0, 0):
with self.subTest("Django 6.0+"):
# Value(None) gets evaluated to "null"
self.assertEqual(changes_dict["json"][1], "null")
else:
with self.subTest("Django < 6.0"):
# Value(None) is preserved as string representation
self.assertEqual(changes_dict["json"][1], "Value(None)")
class UnregisterTest(TestCase): class UnregisterTest(TestCase):
@ -1292,7 +1399,7 @@ class RegisterModelSettingsTest(TestCase):
self.assertTrue(self.test_auditlog.contains(SimpleExcludeModel)) self.assertTrue(self.test_auditlog.contains(SimpleExcludeModel))
self.assertTrue(self.test_auditlog.contains(ChoicesFieldModel)) self.assertTrue(self.test_auditlog.contains(ChoicesFieldModel))
self.assertEqual(len(self.test_auditlog.get_models()), 33) self.assertEqual(len(self.test_auditlog.get_models()), 36)
def test_register_models_register_model_with_attrs(self): def test_register_models_register_model_with_attrs(self):
self.test_auditlog._register_models( self.test_auditlog._register_models(
@ -1666,47 +1773,6 @@ class CharFieldTextFieldModelTest(TestCase):
) )
class PostgresArrayFieldModelTest(TestCase):
databases = "__all__"
def setUp(self):
self.obj = PostgresArrayFieldModel.objects.create(
arrayfield=[PostgresArrayFieldModel.RED, PostgresArrayFieldModel.GREEN],
)
@property
def latest_array_change(self):
return self.obj.history.latest().changes_display_dict["arrayfield"][1]
def test_changes_display_dict_arrayfield(self):
self.assertEqual(
self.latest_array_change,
"Red, Green",
msg="The human readable text for the two choices, 'Red, Green' is displayed.",
)
self.obj.arrayfield = [PostgresArrayFieldModel.GREEN]
self.obj.save()
self.assertEqual(
self.latest_array_change,
"Green",
msg="The human readable text 'Green' is displayed.",
)
self.obj.arrayfield = []
self.obj.save()
self.assertEqual(
self.latest_array_change,
"",
msg="The human readable text '' is displayed.",
)
self.obj.arrayfield = [PostgresArrayFieldModel.GREEN]
self.obj.save()
self.assertEqual(
self.latest_array_change,
"Green",
msg="The human readable text 'Green' is displayed.",
)
class AdminPanelTest(TestCase): class AdminPanelTest(TestCase):
def setUp(self): def setUp(self):
self.user = User.objects.create_user( self.user = User.objects.create_user(
@ -1714,21 +1780,24 @@ class AdminPanelTest(TestCase):
) )
self.site = AdminSite() self.site = AdminSite()
self.admin = LogEntryAdmin(LogEntry, self.site) 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"): with freezegun.freeze_time("2022-08-01 12:00:00Z"):
self.obj = SimpleModel.objects.create(text="For admin logentry test") self.obj = SimpleModel.objects.create(text="For admin logentry test")
def test_auditlog_admin(self): def test_auditlog_admin(self):
self.client.force_login(self.user) self.client.force_login(self.user)
log_pk = self.obj.history.latest().pk 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) 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) 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) 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) 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) self.assertEqual(res.status_code, 200)
def test_created_timezone(self): def test_created_timezone(self):
@ -1737,7 +1806,7 @@ class AdminPanelTest(TestCase):
for tz, timestamp in [ for tz, timestamp in [
("UTC", "2022-08-01 12:00:00"), ("UTC", "2022-08-01 12:00:00"),
("Asia/Tbilisi", "2022-08-01 16:00:00"), ("Asia/Tbilisi", "2022-08-01 16:00:00"),
("America/Buenos_Aires", "2022-08-01 09:00:00"), ("America/Argentina/Buenos_Aires", "2022-08-01 09:00:00"),
("Asia/Kathmandu", "2022-08-01 17:45:00"), ("Asia/Kathmandu", "2022-08-01 17:45:00"),
]: ]:
with self.settings(TIME_ZONE=tz): with self.settings(TIME_ZONE=tz):
@ -1758,7 +1827,7 @@ class AdminPanelTest(TestCase):
def test_cid(self): def test_cid(self):
self.client.force_login(self.user) self.client.force_login(self.user)
expected_response = ( 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>' 'title="Click to filter by records with this correlation id">123</a>'
) )
@ -1766,7 +1835,7 @@ class AdminPanelTest(TestCase):
log_entry.cid = "123" log_entry.cid = "123"
log_entry.save() 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.assertEqual(res.status_code, 200)
self.assertIn(expected_response, res.rendered_content) self.assertIn(expected_response, res.rendered_content)
@ -1774,7 +1843,7 @@ class AdminPanelTest(TestCase):
log = self.obj.history.latest() log = self.obj.history.latest()
obj_pk = self.obj.pk obj_pk = self.obj.pk
delete_log_request = RequestFactory().post( 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.resolver_match = resolve(delete_log_request.path)
delete_log_request.user = self.user delete_log_request.user = self.user
@ -2019,6 +2088,11 @@ class JSONModelTest(TestCase):
{"json": ["{}", '{"quantity": "1"}']}, {"json": ["{}", '{"quantity": "1"}']},
msg="The change is correctly logged", 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): def test_update_with_no_changes(self):
"""No changes are logged.""" """No changes are logged."""
@ -2090,6 +2164,27 @@ class ModelInstanceDiffTest(TestCase):
model_instance_diff(simple2, simple1) model_instance_diff(simple2, simple1)
model_instance_diff(simple1, simple2) 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): def test_object_repr_related_deleted(self):
"""No error is raised when __str__() loads a related object that has been deleted.""" """No error is raised when __str__() loads a related object that has been deleted."""
simple = SimpleModel() simple = SimpleModel()
@ -2344,6 +2439,29 @@ class TestRelatedDiffs(TestCase):
self.assertEqual(int(log_create.changes_dict["related"][1]), one_simple.id) self.assertEqual(int(log_create.changes_dict["related"][1]), one_simple.id)
self.assertEqual(int(log_update.changes_dict["related"][1]), two_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): class TestModelSerialization(TestCase):
def setUp(self): def setUp(self):
@ -2611,6 +2729,7 @@ class TestAccessLog(TestCase):
) )
self.assertIsNone(log_entry.changes) self.assertIsNone(log_entry.changes)
self.assertEqual(log_entry.changes_dict, {}) self.assertEqual(log_entry.changes_dict, {})
self.assertEqual(log_entry.changes_str, "")
class SignalTests(TestCase): class SignalTests(TestCase):
@ -2771,7 +2890,7 @@ class SignalTests(TestCase):
self.assertSignals(LogEntry.Action.DELETE) self.assertSignals(LogEntry.Action.DELETE)
@patch("auditlog.receivers.LogEntry.objects") @patch.object(LogEntry, "objects")
def test_signals_errors(self, log_entry_objects_mock): def test_signals_errors(self, log_entry_objects_mock):
class CustomSignalError(BaseException): class CustomSignalError(BaseException):
pass pass
@ -2906,6 +3025,139 @@ class ModelManagerTest(TestCase):
self.assertEqual(log.changes_dict["name"], ["Public", "Updated"]) 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): class TestMaskStr(TestCase):
"""Test the mask_str function that masks sensitive data.""" """Test the mask_str function that masks sensitive data."""
@ -2963,3 +3215,91 @@ class CustomMaskModelTest(TestCase):
"****7654", "****7654",
msg="The custom masking function should be used in serialized data", 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

@ -3,3 +3,4 @@ django>=4.2,<4.3
sphinx sphinx
sphinx_rtd_theme sphinx_rtd_theme
psycopg2-binary psycopg2-binary
mysqlclient==2.2.5

View file

@ -11,10 +11,10 @@ The repository can be found at https://github.com/jazzband/django-auditlog/.
**Requirements** **Requirements**
- Python 3.9 or higher - Python 3.10 or higher
- Django 4.2, 5.0, 5.1, and 5.2 - 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. at https://github.com/jazzband/django-auditlog/actions.
Adding Auditlog to your Django application 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. 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 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. 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 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 # In your_app/utils.py
def custom_mask(value: str) -> str: def custom_mask(value: str) -> str:
return "****" + value[-4:] # Only show last 4 characters return "****" + value[-4:] # Only show last 4 characters
# In your models.py # In your models.py
auditlog.register( auditlog.register(
MyModel, MyModel,
@ -253,7 +253,7 @@ It will be considered when ``AUDITLOG_INCLUDE_ALL_MODELS`` is `True`.
.. versionadded:: 3.0.0 .. versionadded:: 3.0.0
**AUDITLOG_EXCLUDE_TRACKING_FIELDS** **AUDITLOG_DISABLE_REMOTE_ADDR**
When using "AuditlogMiddleware", When using "AuditlogMiddleware",
the IP address is logged by default, you can use this setting 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 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 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. in plain text but need to be auditable.
When a masked field changes, its value will be replaced with a masked 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. 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 .. code-block:: python
@ -377,6 +377,108 @@ This means that primitives such as booleans, integers, etc. will be represented
.. versionadded:: 3.2.0 .. 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 Actors
------ ------
@ -551,3 +653,26 @@ Django Admin integration
When ``auditlog`` is added to your ``INSTALLED_APPS`` setting a customized admin class is active providing an enhanced When ``auditlog`` is added to your ``INSTALLED_APPS`` setting a customized admin class is active providing an enhanced
Django Admin interface for log entries. Django Admin interface for log entries.
Audit log history view
----------------------
.. versionadded:: 3.2.2
Use ``AuditlogHistoryAdminMixin`` to add a "View" link in the admin changelist for accessing each object's audit history::
from auditlog.mixins import AuditlogHistoryAdminMixin
@admin.register(MyModel)
class MyModelAdmin(AuditlogHistoryAdminMixin, admin.ModelAdmin):
show_auditlog_history_link = True
The history page displays paginated log entries with user, timestamp, action, and field changes. Override
``auditlog_history_template`` to customize the page layout.
The mixin provides the following configuration options:
- ``show_auditlog_history_link``: Set to ``True`` to display the "View" link in the admin changelist
- ``auditlog_history_template``: Template to use for rendering the history page (default: ``auditlog/object_history.html``)
- ``auditlog_history_per_page``: Number of log entries to display per page (default: 10)

43
runtests.sh Executable file
View file

@ -0,0 +1,43 @@
#!/usr/bin/env bash
# Run tests against all supported databases
set -e
# Default settings
export TEST_DB_USER=${TEST_DB_USER:-testuser}
export TEST_DB_PASS=${TEST_DB_PASS:-testpass}
export TEST_DB_HOST=${TEST_DB_HOST:-127.0.0.1}
export TEST_DB_NAME=${TEST_DB_NAME:-auditlog}
# Cleanup on exit
trap 'docker compose -f auditlog_tests/docker-compose.yml down -v --remove-orphans 2>/dev/null || true' EXIT
echo "Starting containers..."
docker compose -f auditlog_tests/docker-compose.yml up -d
echo "Waiting for databases..."
echo "Waiting for PostgreSQL..."
until docker compose -f auditlog_tests/docker-compose.yml exec postgres pg_isready -U ${TEST_DB_USER} -d auditlog >/dev/null 2>&1; do
sleep 1
done
echo "Waiting for MySQL..."
until docker compose -f auditlog_tests/docker-compose.yml exec mysql mysqladmin ping -h 127.0.0.1 -u ${TEST_DB_USER} --password=${TEST_DB_PASS} --silent >/dev/null 2>&1; do
sleep 1
done
echo "Databases ready!"
# Run tests for each database
for backend in sqlite3 postgresql mysql; do
echo "Testing $backend..."
export TEST_DB_BACKEND=$backend
case $backend in
postgresql) export TEST_DB_PORT=5432 ;;
mysql) export TEST_DB_PORT=3306;;
sqlite3) unset TEST_DB_PORT ;;
esac
tox
done
echo "All tests completed!"

View file

@ -10,11 +10,13 @@ setup(
name="django-auditlog", name="django-auditlog",
use_scm_version={"version_scheme": "post-release"}, use_scm_version={"version_scheme": "post-release"},
setup_requires=["setuptools_scm"], setup_requires=["setuptools_scm"],
include_package_data=True,
packages=[ packages=[
"auditlog", "auditlog",
"auditlog.migrations", "auditlog.migrations",
"auditlog.management", "auditlog.management",
"auditlog.management.commands", "auditlog.management.commands",
"auditlog.templatetags",
], ],
url="https://github.com/jazzband/django-auditlog", url="https://github.com/jazzband/django-auditlog",
project_urls={ project_urls={
@ -27,12 +29,11 @@ setup(
description="Audit log app for Django", description="Audit log app for Django",
long_description=long_description, long_description=long_description,
long_description_content_type="text/markdown", long_description_content_type="text/markdown",
python_requires=">=3.9", python_requires=">=3.10",
install_requires=["Django>=4.2", "python-dateutil>=2.7.0"], install_requires=["Django>=4.2", "python-dateutil>=2.7.0"],
zip_safe=False, zip_safe=False,
classifiers=[ classifiers=[
"Programming Language :: Python :: 3", "Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.12",

34
tox.ini
View file

@ -1,17 +1,20 @@
[tox] [tox]
envlist = envlist =
{py39,py310,py311}-django42 {py312}-customlogmodel-django52
{py310,py311}-django42
{py310,py311,py312}-django50 {py310,py311,py312}-django50
{py310,py311,py312,py313}-django51 {py310,py311,py312,py313}-django51
{py310,py311,py312,py313}-django52 {py310,py311,py312,py313}-django52
{py312,py313}-djangomain {py312,py313}-djangomain
py39-docs py310-docs
py39-lint py310-lint
py39-checkmigrations py310-checkmigrations
[testenv] [testenv]
setenv = setenv =
COVERAGE_FILE={toxworkdir}/.coverage.{envname} COVERAGE_FILE={toxworkdir}/.coverage.{envname}.{env:TEST_DB_BACKEND}
customlogmodel: AUDITLOG_LOGENTRY_MODEL = custom_logentry_app.CustomLogEntryModel
changedir = auditlog_tests changedir = auditlog_tests
commands = commands =
coverage run --source auditlog ./manage.py test coverage run --source auditlog ./manage.py test
@ -27,7 +30,10 @@ deps =
codecov codecov
freezegun freezegun
psycopg2-binary psycopg2-binary
mysqlclient
passenv= passenv=
TEST_DB_BACKEND
TEST_DB_HOST TEST_DB_HOST
TEST_DB_USER TEST_DB_USER
TEST_DB_PASS TEST_DB_PASS
@ -39,30 +45,36 @@ basepython =
py312: python3.12 py312: python3.12
py311: python3.11 py311: python3.11
py310: python3.10 py310: python3.10
py39: python3.9
[testenv:py39-docs] [testenv:py310-docs]
changedir = docs/source changedir = docs/source
deps = -rdocs/requirements.txt deps = -rdocs/requirements.txt
commands = sphinx-build -W -b html -d {envtmpdir}/doctrees . {envtmpdir}/html commands = sphinx-build -W -b html -d {envtmpdir}/doctrees . {envtmpdir}/html
[testenv:py39-lint] [testenv:py310-lint]
deps = pre-commit deps = pre-commit
commands = commands =
pre-commit run --all-files pre-commit run --all-files
[testenv:py39-checkmigrations] [testenv:py310-checkmigrations]
description = Check for missing migrations description = Check for missing migrations
changedir = auditlog_tests changedir = auditlog_tests
deps = deps =
Django>=4.2 Django>=4.2
psycopg2 psycopg2-binary
mysqlclient
passenv=
TEST_DB_BACKEND
TEST_DB_HOST
TEST_DB_USER
TEST_DB_PASS
TEST_DB_NAME
TEST_DB_PORT
commands = commands =
python manage.py makemigrations --check --dry-run python manage.py makemigrations --check --dry-run
[gh-actions] [gh-actions]
python = python =
3.9: py39
3.10: py310 3.10: py310
3.11: py311 3.11: py311
3.12: py312 3.12: py312