Compare commits

..

94 commits

Author SHA1 Message Date
Alieh Rymašeŭski
a13f8d9e60 Pin psycopg2 to version compatible with Django 2.2 2021-06-25 12:21:03 +03:00
Alieh Rymašeŭski
d7a73e5cbf Merge changes from upstream 2021-06-24 15:19:21 +03:00
Alieh Rymašeŭski
f12e8a74fc Remove multiselectfield from tests
The upstream dropped this test long ago, and these tests now break on
Django 3.1+, so I don't want to invest into these tests too much.
2021-06-24 14:58:26 +03:00
Alieh Rymašeŭski
1fc1e678c7 Replace list() with get_models() 2021-06-24 14:57:30 +03:00
Hasan Ramezani
d067de73b6 Replace MIDDLEWARE_CLASSES with MIDDLEWARE in docs. 2021-06-24 14:41:16 +03:00
Hasan Ramezani
cb99bcfbc4 Add DEFAULT_AUTO_FIELD to test settings. 2021-06-24 14:41:14 +03:00
Hasan Ramezani
093b8093de Add Django 3.2 support. 2021-06-24 14:41:09 +03:00
Hasan Ramezani
cf63860dad Remove Django 3.0 support. 2021-06-24 14:41:05 +03:00
Blas Isaias Fernández
943d5a9e6b dict.iteritems was removed from python 3 2021-06-24 14:40:36 +03:00
Hasan Ramezani
ea57dfd9c1 Remove Python 3.5 support. (#301) 2021-06-24 14:40:12 +03:00
Jannis Leidel
ccf4b69113 Remove note about maintenance. 2021-06-24 14:39:53 +03:00
Hasan Ramezani
327f8c7067 Add Django supported versions to setup.py classifiers. 2021-06-24 14:39:42 +03:00
Hasan Ramezani
94182f86e9 Replace old-style routing with new-style 2021-06-24 14:37:32 +03:00
Hasan Ramezani
59bf633fbf Add Supported Django versions badge to README. 2021-06-24 14:37:15 +03:00
Alieh Rymašeŭski
783316331f Support Python 3.9 2021-06-24 14:37:07 +03:00
Alieh Rymašeŭski
e404e82795 Apply CI changes from upstream
Notable changes include:
- removal of Travis in favor of GH Actions;
- configuration to get version from git and not from code.
2021-06-24 14:37:03 +03:00
Alieh Rymašeŭski
458f3e3766 Bump version to 0.7.3 2021-06-24 14:33:46 +03:00
Alieh Rymašeŭski
4232d685bd Apply isort to the code base 2021-06-24 14:33:30 +03:00
Alieh Rymašeŭski
49d92b30fe Replace relative imports with absolute 2021-06-24 14:31:23 +03:00
Alieh Rymašeŭski
b130e3088e Add isort 2021-06-24 14:31:18 +03:00
Alieh Rymašeŭski
7801239387 Blacken the code 2021-06-24 13:04:48 +03:00
Alieh Rymašeŭski
c57be3a2c1 Add black 2021-06-24 13:03:36 +03:00
Alieh Rymašeŭski
5d2bc88b2d Test LogEntry.actor field 2021-06-24 12:55:22 +03:00
Alieh Rymašeŭski
f647966210 Stop handling an impossible case
We check eliminate the case with zero log entries when checking that
obj.history.count() is exactly 1.
2021-06-24 12:55:22 +03:00
Alieh Rymašeŭski
2b44eebd50 Use assertEqual to assert equality 2021-06-24 12:55:22 +03:00
Alieh Rymašeŭski
6c0c83e7e5 Remove unused imports
Import of RelatedModel was left in place as it just lacks respective
tests.
2021-06-24 12:55:22 +03:00
Alieh Rymašeŭski
034ba57d93 Fix a misleading docstring 2021-06-24 12:55:22 +03:00
Alieh Rymašeŭski
13cad5b25a Explicitly unset the threadlocal 2021-06-24 12:55:22 +03:00
Alieh Rymašeŭski
9629f3f8d7 Move signal management to a context manager
This change allows setting the same signals when the request is not
present, i.e. in a celery task.
2021-06-24 12:55:22 +03:00
Alieh Rymašeŭski
3eb5d66c39 Use get_user_model 2021-06-24 12:55:22 +03:00
Alieh Rymašeŭski
a0a0726982 Bump version to 0.7.2 2020-11-18 13:29:50 +03:00
Alieh Rymašeŭski
ff6349a89a Order filter by field alphabetically 2020-11-18 13:29:29 +03:00
Alieh Rymašeŭski
8b97cc2acb Bump version to 0.7.1 2020-11-12 18:04:00 +03:00
Alieh Rymašeŭski
5dcb069bb8 Add a short filter by actor__isnull 2020-11-12 18:03:27 +03:00
Alieh Rymašeŭski
d7a6496ad8 Bump version to 0.7.0 2020-11-12 17:59:02 +03:00
Alieh Rymašeŭski
df16b2a8da Changes for Jazzband (#269)
* Update repository references to Jazzband
Issue #268

* Add Jazzband badge to README
Issue #268

* Add Jazzband contribution guideline
Issue #268

Cherry-picking 31418d54f2
2020-11-12 16:51:20 +03:00
Alieh Rymašeŭski
be82018266 Use more generic .pk to get primary key instead of .id
Fixes #140

Cherry-picking fdf6ed7149c2731e3bd9144f33a5d70418e05889
2020-11-12 16:50:12 +03:00
Alieh Rymašeŭski
de693dd092 Management command improvements
Cherry-picking f4edfc0592
2020-11-12 16:50:12 +03:00
Alieh Rymašeŭski
6379683f77 Remove stale code
Cherry-picking f14f6b34ee
2020-11-12 16:41:42 +03:00
Alieh Rymašeŭski
c346adb8b5 Code improvements
Cherry-picking 469fe362de
2020-11-12 16:40:48 +03:00
Alieh Rymašeŭski
701f867a04 Remove a deprecated alias 2020-11-12 16:32:41 +03:00
Alieh Rymašeŭski
0ca00faafc Simplify field.base_field.choices check 2020-11-12 16:21:31 +03:00
Alieh Rymašeŭski
cb1fefb793 Allow setting database parameters through env 2020-11-12 16:21:09 +03:00
Alieh Rymašeŭski
536a841bf3 Remove Python 2 cruft 2020-11-12 16:15:32 +03:00
Alieh Rymašeŭski
08a7b82acc Remove version constraints on test requirements 2020-11-12 16:15:25 +03:00
Alieh Rymašeŭski
8d2bb0f319 Drop support for outdated Django versions 2020-11-12 16:15:12 +03:00
Alieh Rymašeŭski
3ac4311ae4 Align Travis test matrix with tox.ini 2020-11-12 16:15:12 +03:00
Alieh Rymašeŭski
917b490ee4 Bump copyright year
Cherry-picking 4e7c640ba0
2020-11-12 16:15:12 +03:00
Alieh Rymašeŭski
47a268eef9 Clean up project structure
Cherry-picking ee8a700b1b
2020-11-12 16:15:05 +03:00
Alieh Rymašeŭski
ca29848d78 Remove compatibility code for old Django versions 2020-11-12 12:31:23 +03:00
Alieh Rymašeŭski
a6cea38c9e Bump version to 0.6.10 2020-06-11 17:05:29 +03:00
Alieh Rymašeŭski
33a35e841b Export the logic to limit database query time 2020-06-11 17:05:29 +03:00
Alieh Rymašeŭski
a11d1a3e46 Bump version to 0.6.9 2020-06-10 15:40:07 +03:00
Alieh Rymašeŭski
45a27ec1d6 Support Django 3.0 2020-06-10 15:31:41 +03:00
Alieh Rymašeŭski
e6f3f12bae Relax requirements for tests 2020-06-10 15:21:48 +03:00
Alieh Rymašeŭski
28a60af5f3 Move Travis-specific requirements into own file 2020-06-10 15:21:48 +03:00
Alieh Rymašeŭski
fd169771df Support running tox -p 2020-06-10 15:21:48 +03:00
Alieh Rymašeŭski
d97ac056d4 Remove unused requirements.txt
tox installs the dependencies specified in setup.py.
2020-06-10 14:20:39 +03:00
Alieh Rymašeŭski
33c35b42db Support python 3.8 2020-06-10 14:08:33 +03:00
Alieh Rymašeŭski
0f3c7b430f Bump version to 0.6.8 2020-04-22 17:57:21 +03:00
Alieh Rymašeŭski
bf8ba7a0be Add admin filter by timestamp
It's backed by django-admin-rangefilter if able.
2020-04-22 17:42:53 +03:00
Alieh Rymašeŭski
2b536fc87e Bump version to 0.6.7 2020-04-22 16:09:27 +03:00
Alieh Rymašeŭski
106417c684 Display timestamps in server timezone 2020-04-22 16:03:01 +03:00
Alieh Rymašeŭski
358971aafe Bump version to 0.6.6 2020-04-17 12:54:54 +03:00
Alieh Rymašeŭski
f58b3d7685 Introduce admin filter by changed field 2020-04-17 12:52:56 +03:00
Alieh Rymašeŭski
5cd55ac38c Bump version to 0.6.5 2020-04-07 12:03:09 +03:00
Alieh Rymašeŭski
8397754a20 Replace dumb paginator with a time-limited one 2020-04-07 11:55:55 +03:00
Alieh Rymašeŭski
da49432924 Bump version to 0.6.4 2020-04-07 11:23:48 +03:00
Alieh Rymašeŭski
3af06e13c7 Add a missing line to manifest 2020-04-07 11:17:39 +03:00
Alieh Rymašeŭski
ae57b0c322 Merge upstream changes from jjkester/master
Conflicts:
    setup.py  - conflicting version bump, expected
    src/auditlog_tests/router.py - removed by them
    tox.ini - upstream extended test matrix with dj21 and dj22, we did
              the same but dropped older versions, keepign our variant
2020-04-07 11:13:45 +03:00
Alieh Rymašeŭski
bde49bdb4f Allow newer versions of dateutil 2019-11-25 15:49:00 +03:00
Alieh Rymašeŭski
c66b36c700 Bump version to 0.6.2 2019-08-22 18:24:46 +03:00
Alieh Rymašeŭski
2ae401a04f Shave off two SELECT COUNT(*) from admin page 2019-08-22 18:24:10 +03:00
Alieh Rymašeŭski
5ba554af56 Bump version to 0.6.1 2019-08-22 18:06:48 +03:00
Alieh Rymašeŭski
df2bf0a05c Replace timestamp index with timestamp+id index
This change is supposed to improve performance of admin view.
2019-08-22 18:05:39 +03:00
Alieh Rymašeŭski
05e6b179fd Bump version to 0.6.0 2019-08-22 16:31:08 +03:00
Alieh Rymašeŭski
07b38a9345 Update references to the maintainer 2019-08-22 16:31:08 +03:00
Alieh Rymašeŭski
5784247180 Update requirements section in the docs
We've dropped support for Python2 and unsupported versions of Python
and Django.
2019-08-22 16:31:08 +03:00
Alieh Rymašeŭski
2a43cff96f Add python3.7 to tox and classifiers 2019-08-22 16:31:08 +03:00
Alieh Rymašeŭski
b16b1a0df3 Drop Django 1.9 compatibility 2019-08-22 16:31:08 +03:00
Alieh Rymašeŭski
9152d225bb Remove obsolete versions from setup and tox 2019-08-22 16:31:08 +03:00
Alieh Rymašeŭski
c53b766132 Drop python2.7 support
Our dependency jsonfield broke compatibility with python2.7 recently,
and having no real reasons to support python2.7 we just drop it now.
2019-08-22 16:31:08 +03:00
Alieh Rymašeŭski
e35d0f4194 Bump version to 0.5.3 2019-08-21 19:41:02 +03:00
Alieh Rymašeŭski
e60876ae14 Add index for timestamp 2019-08-21 19:30:25 +03:00
Alieh Rymašeŭski
5dbea8a9a1 Bump version to 0.5.2 2019-05-17 12:41:31 +03:00
Alieh Rymašeŭski
cfbc588cc1 Query ContentTypes instead of distinct LogEntry
SELECT DISTINCT app_label, model FROM log_entry is a very expensive
request for longer logs, while we can always get the list of all
tracked models straight from the registry.

This new approach has two downsides:
1. It only provides filters for currently tracked models.
2. It can list such filter options that don't have any log entries.
2019-05-17 12:38:39 +03:00
Alieh Rymašeŭski
ee6bb33bc9 Bump version to 0.5.1 2019-05-16 17:24:13 +03:00
Alieh Rymašeŭski
c9c97b6861 Improve admin list performance 2019-05-16 17:23:28 +03:00
Alieh Rymašeŭski
5f5cc7f7e9 Bump version to 0.5.0 2019-05-11 18:43:36 +03:00
Alieh Rymašeŭski
a5381b6195 Move signal management to a context manager
This change allows setting the same signals when the request is not
present, i.e. in a celery task.
2019-05-11 18:25:08 +03:00
Alieh Rymašeŭski
2dc0ac43b5 Use get_user_model 2019-05-11 18:16:00 +03:00
Alieh Rymašeŭski
aa28009d3b Configure tests for Django 2.2 2019-05-11 18:16:00 +03:00
Alieh Rymašeŭski
03b8616dac Configure tests for Django 2.1 2019-05-11 18:15:58 +03:00
Alieh Rymašeŭski
62c1e676cc Bump version 2019-03-28 14:29:31 +03:00
104 changed files with 975 additions and 8332 deletions

View file

@ -1,30 +0,0 @@
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

@ -1,13 +0,0 @@
# Keep GitHub Actions up to date with GitHub's Dependabot...
# https://docs.github.com/en/code-security/dependabot/working-with-dependabot/keeping-your-actions-up-to-date-with-dependabot
# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#package-ecosystem
version: 2
updates:
- package-ecosystem: github-actions
directory: /
groups:
github-actions:
patterns:
- "*" # Group all Actions updates into a single larger pull request
schedule:
interval: weekly

View file

@ -11,22 +11,22 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v6
uses: actions/setup-python@v2
with:
python-version: '3.10'
python-version: 3.8
- name: Get pip cache dir
id: pip-cache
run: |
echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT
echo "::set-output name=dir::$(pip cache dir)"
- name: Cache
uses: actions/cache@v5
uses: actions/cache@v2
with:
path: ${{ steps.pip-cache.outputs.dir }}
key: release-${{ hashFiles('**/setup.py') }}
@ -36,7 +36,7 @@ jobs:
- name: Install dependencies
run: |
python -m pip install -U pip
python -m pip install -U setuptools==75.6.0 twine==6.0.1 wheel pkginfo
python -m pip install -U setuptools twine wheel
- name: Build package
run: |
@ -46,8 +46,8 @@ jobs:
- name: Upload packages to Jazzband
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
uses: pypa/gh-action-pypi-publish@release/v1
uses: pypa/gh-action-pypi-publish@master
with:
user: jazzband
password: ${{ secrets.JAZZBAND_RELEASE_KEY }}
repository-url: https://jazzband.co/projects/django-auditlog/upload
repository_url: https://jazzband.co/projects/django-auditlog/upload

View file

@ -3,125 +3,67 @@ name: Test
on: [push, pull_request]
jobs:
test-sqlite:
name: SQLite • Python ${{ matrix.python-version }}
build:
runs-on: ubuntu-latest
strategy:
fail-fast: false
max-parallel: 5
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v6
python-version: ['3.6', '3.7', '3.8', '3.9']
- 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:
postgres:
image: postgres:15
image: postgres:10
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: auditlog
POSTGRES_DB: postgres
ports:
- 5432/tcp
- 5432/tcp
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 10
--health-retries 5
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v2
- name: Setup Python and dependencies
uses: ./.github/actions/setup-python-deps
with:
python-version: ${{ matrix.python-version }}
cache-key-prefix: postgresql
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- name: Run tests
env:
TEST_DB_BACKEND: postgresql
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: Get pip cache dir
id: pip-cache
run: |
echo "::set-output name=dir::$(pip cache dir)"
run: tox -v
- name: Cache
uses: actions/cache@v2
with:
path: ${{ steps.pip-cache.outputs.dir }}
key:
-${{ matrix.python-version }}-v1-${{ hashFiles('**/setup.py') }}
restore-keys: |
-${{ matrix.python-version }}-v1-
- name: Upload coverage
uses: codecov/codecov-action@v5
with:
name: PostgreSQL • Python ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install --upgrade tox tox-gh-actions
test-mysql:
name: MySQL • Python ${{ matrix.python-version }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13"]
services:
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: Tox tests
run: |
tox -v
env:
TEST_DB_HOST: localhost
TEST_DB_USER: postgres
TEST_DB_PASS: postgres
TEST_DB_NAME: postgres
TEST_DB_PORT: ${{ job.services.postgres.ports[5432] }}
- name: Install MySQL client libraries
run: |
sudo apt-get update
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 }}
- name: Upload coverage
uses: codecov/codecov-action@v1
with:
name: Python ${{ matrix.python-version }}

2
.gitignore vendored
View file

@ -48,6 +48,7 @@ coverage.xml
cover/
# Translations
*.mo
*.pot
# Django stuff:
@ -79,4 +80,3 @@ venv.bak/
### JetBrains
.idea/
.vscode/

View file

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

View file

@ -1,27 +0,0 @@
# Read the Docs configuration file for Sphinx projects
# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
# Required
version: 2
# Set the OS, Python version and other tools you might need
build:
os: ubuntu-22.04
tools:
python: "3"
# Build documentation in the "docs/" directory with Sphinx
sphinx:
configuration: docs/source/conf.py
# Optionally build your docs in additional formats such as PDF and ePub
formats: all
# Optional but recommended, declare the Python requirements required
# to build your documentation
# See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html
python:
install:
- requirements: docs/requirements.txt
- method: pip
path: .

View file

@ -1,448 +0,0 @@
# Changes
## 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)
#### Improvements
- Confirm Django 5.2 support. ([#730](https://github.com/jazzband/django-auditlog/pull/730))
#### Fixes
- fix: ```AUDITLOG_STORE_JSON_CHANGES=True``` was not respected during updates and deletions. ([#732](https://github.com/jazzband/django-auditlog/pull/732))
## 3.2.0 (2025-06-26)
#### Improvements
- feat: Support storing JSON in the changes field when ```AUDITLOG_STORE_JSON_CHANGES``` is enabled. ([#719](https://github.com/jazzband/django-auditlog/pull/719))
- feat: Added `AUDITLOG_MASK_CALLABLE` setting to allow custom masking functions ([#725](https://github.com/jazzband/django-auditlog/pull/725))
## 3.1.2 (2025-04-26)
#### Fixes
- CI: Pine twine and setuptools to fix release
## 3.1.1 (2025-04-16)
#### Fixes
- CI: Add required pkginfo to release workflow
## 3.1.0 (2025-04-15)
#### Improvements
- feat: Support masking field names globally when ```AUDITLOG_INCLUDE_ALL_MODELS``` is enabled
via `AUDITLOG_MASK_TRACKING_FIELDS` setting. ([#702](https://github.com/jazzband/django-auditlog/pull/702))
- feat: Added `LogEntry.actor_email` field. ([#641](https://github.com/jazzband/django-auditlog/pull/641))
- Add Python 3.13 support. ([#697](https://github.com/jazzband/django-auditlog/pull/671))
- feat: Added `LogEntry.remote_port` field. ([#671](https://github.com/jazzband/django-auditlog/pull/671))
- feat: Added `truncate` option to `auditlogflush` management command. ([#681](https://github.com/jazzband/django-auditlog/pull/681))
- feat: Added `AUDITLOG_CHANGE_DISPLAY_TRUNCATE_LENGTH` settings to keep or truncate strings of `changes_display_dict` property at variable length. ([#684](https://github.com/jazzband/django-auditlog/pull/684))
- Drop Python 3.8 support. ([#678](https://github.com/jazzband/django-auditlog/pull/678))
- Confirm Django 5.1 support and drop Django 3.2 support. ([#677](https://github.com/jazzband/django-auditlog/pull/677))
#### Fixes
- fix: Use sender instead of receiver for `m2m_changed` signal ID to prevent duplicate entries for models that share a related model. ([#686](https://github.com/jazzband/django-auditlog/pull/686))
- Fixed a problem when setting `Value(None)` in `JSONField` ([#646](https://github.com/jazzband/django-auditlog/pull/646))
- Fixed a problem when setting `django.db.models.functions.Now()` in `DateTimeField` ([#635](https://github.com/jazzband/django-auditlog/pull/635))
- Use the [default manager](https://docs.djangoproject.com/en/5.1/topics/db/managers/#default-managers) instead of `objects` to support custom model managers. ([#705](https://github.com/jazzband/django-auditlog/pull/705))
- Fixed crashes when cloning objects with `pk=None` ([#707](https://github.com/jazzband/django-auditlog/pull/707))
## 3.0.0 (2024-04-12)
#### Fixes
- Fixed logging problem related to django translation before logging ([#624](https://github.com/jazzband/django-auditlog/pull/624))
- Fixed manuall logging when model is not registered ([#627](https://github.com/jazzband/django-auditlog/pull/627))
#### Improvements
- feat: Excluding ip address when `AUDITLOG_DISABLE_REMOTE_ADDR` is set to True ([#620](https://github.com/jazzband/django-auditlog/pull/620))
## 3.0.0-beta.4 (2024-01-02)
#### Improvements
- feat: If any receiver returns False, no logging will be made. This can be useful if logging should be conditionally enabled / disabled ([#590](https://github.com/jazzband/django-auditlog/pull/590))
- Django: Confirm Django 5.0 support ([#598](https://github.com/jazzband/django-auditlog/pull/598))
- Django: Drop Django 4.1 support ([#598](https://github.com/jazzband/django-auditlog/pull/598))
## 3.0.0-beta.3 (2023-11-13)
#### Improvements
- Python: Confirm Python 3.12 support ([#572](https://github.com/jazzband/django-auditlog/pull/572))
- feat: `thread.local` replaced with `ContextVar` to improve context managers in Django 4.2+
#### Fixes
- fix: Handle `ObjectDoesNotExist` in evaluation of `object_repr` ([#592](https://github.com/jazzband/django-auditlog/pull/592))
## 3.0.0-beta.2 (2023-10-05)
#### Breaking Changes
- feat: stop deleting old log entries when a model with the same pk is created (i.e. the pk value is reused) ([#559](https://github.com/jazzband/django-auditlog/pull/559))
#### Fixes
* fix: only fire the `post_log` signal when the log is created or when there is an error in the process ([#561](https://github.com/jazzband/django-auditlog/pull/561))
* fix: don't set the correlation_id if the `AUDITLOG_CID_GETTER` is `None` ([#565](https://github.com/jazzband/django-auditlog/pull/565))
## 3.0.0-beta.1 (2023-08-29)
#### Breaking Changes
- feat: Change `LogEntry.change` field type to `JSONField` rather than `TextField`. This change include a migration that may take time to run depending on the number of records on your `LogEntry` table ([#407](https://github.com/jazzband/django-auditlog/pull/407))([#495](https://github.com/jazzband/django-auditlog/pull/495))
- Python: Drop support for Python 3.7 ([#546](https://github.com/jazzband/django-auditlog/pull/546))
- feat: Set `AuditlogHistoryField.delete_related` to `False` by default. This is different from the default configuration of Django's `GenericRelation`, but we should not erase the audit log of objects on deletion by default. ([#557](https://github.com/jazzband/django-auditlog/pull/557))
#### Improvements
- Changes the view when it has changes in fields `JSONField`. The `JSONField.encoder` is assigned to `json.dumps`. ([#489](https://github.com/jazzband/django-auditlog/pull/489))
- feat: Added support for Correlation ID. ([#481](https://github.com/jazzband/django-auditlog/pull/481))
- feat: Added pre-log and post-log signals. ([#483](https://github.com/jazzband/django-auditlog/pull/483))
- feat: Make timestamp in LogEntry overwritable. ([#476](https://github.com/jazzband/django-auditlog/pull/476))
- feat: Support excluding field names globally when ```AUDITLOG_INCLUDE_ALL_MODELS``` is enabled. ([#498](https://github.com/jazzband/django-auditlog/pull/498))
- feat: Improved auto model registration to include auto-created models and exclude non-managed models, and automatically register m2m fields for models. ([#550](https://github.com/jazzband/django-auditlog/pull/550))
#### Fixes
- fix: Audit changes to FK fields when saved using `*_id` naming. ([#525](https://github.com/jazzband/django-auditlog/pull/525))
- fix: Fix a bug in audit log admin page when `USE_TZ=False`. ([#511](https://github.com/jazzband/django-auditlog/pull/511))
- fix: Make sure `LogEntry.changes_dict()` returns an empty dict instead of `None` when `json.loads()` returns `None`. ([#472](https://github.com/jazzband/django-auditlog/pull/472))
- fix: Always set remote_addr even if the request has no authenticated user. ([#484](https://github.com/jazzband/django-auditlog/pull/484))
- fix: Fix a bug in getting field's `verbose_name` when model is not accessible. ([508](https://github.com/jazzband/django-auditlog/pull/508))
- fix: Fix a bug in `serialized_data` with F expressions. ([508](https://github.com/jazzband/django-auditlog/pull/508))
- fix: Make log entries read-only in the admin. ([#449](https://github.com/jazzband/django-auditlog/pull/449), [#556](https://github.com/jazzband/django-auditlog/pull/556)) (applied again after being reverted in 2.2.2)
## 2.2.2 (2023-01-16)
#### Fixes
- fix: revert [#449](https://github.com/jazzband/django-auditlog/pull/449) "Make log entries read-only in the admin" as it breaks deletion of any auditlogged model through the admin when `AuditlogHistoryField` is used. ([#496](https://github.com/jazzband/django-auditlog/pull/496))
## 2.2.1 (2022-11-28)
#### Fixes
- fix: Make log entries read-only in the admin. ([#449](https://github.com/jazzband/django-auditlog/pull/449))
- fix: Handle IPv6 addresses in `X-Forwarded-For`. ([#457](https://github.com/jazzband/django-auditlog/pull/457))
## 2.2.0 (2022-10-07)
#### Improvements
- feat: Add `ACCESS` action to `LogEntry` model and allow object access to be logged. ([#436](https://github.com/jazzband/django-auditlog/pull/436))
- feat: Add `serialized_data` field on `LogEntry` model. ([#412](https://github.com/jazzband/django-auditlog/pull/412))
- feat: Display the field name as it would be displayed in Django Admin or use `mapping_field` if available [#428](https://github.com/jazzband/django-auditlog/pull/428)
- feat: New context manager `disable_auditlog` to turn off logging and a new setting `AUDITLOG_DISABLE_ON_RAW_SAVE`
to disable it during raw-save operations like loaddata. [#446](https://github.com/jazzband/django-auditlog/pull/446)
- Python: Confirm Python 3.11 support ([#447](https://github.com/jazzband/django-auditlog/pull/447))
- feat: Replace the `django.utils.timezone.utc` by `datetime.timezone.utc`. [#448](https://github.com/jazzband/django-auditlog/pull/448)
#### Fixes
- fix: Foreign key values are used to check for changes in related fields instead of object representations. When changes are detected, the foreign key value is persisted in `LogEntry.changes` field instead of object representations. ([#420](https://github.com/jazzband/django-auditlog/pull/420))
- fix: Display `created` timestamp in server timezone ([#404](https://github.com/jazzband/django-auditlog/pull/404))
- fix: Handle port in `remote_addr` ([#417](https://github.com/jazzband/django-auditlog/pull/417))
- fix: Handle the error with AttributeError: 'OneToOneRel' error occur during a `PolymorphicModel` has relation with other models ([#429](https://github.com/jazzband/django-auditlog/pull/429))
- fix: Support search by custom USERNAME_FIELD ([#432](https://github.com/jazzband/django-auditlog/pull/432))
## 2.1.1 (2022-07-27)
#### Improvements
- feat: Display the diff for deleted objects in the admin ([#396](https://github.com/jazzband/django-auditlog/pull/396))
- Django: Confirm Django 4.1 support ([#406](https://github.com/jazzband/django-auditlog/pull/406))
#### Fixes
- fix: Pin `python-dateutil` to 2.7.0 or higher for compatibility with Python 3.10 ([#401](https://github.com/jazzband/django-auditlog/pull/401))
## 2.1.0 (2022-06-27)
#### Improvements
- feat: Add `--before-date` option to `auditlogflush` to support retention windows ([#365](https://github.com/jazzband/django-auditlog/pull/365))
- feat: Add db_index to the `LogEntry.timestamp` column ([#364](https://github.com/jazzband/django-auditlog/pull/364))
- feat: Add register model from settings ([#368](https://github.com/jazzband/django-auditlog/pull/368))
- Context manager set_actor() for use in Celery tasks ([#262](https://github.com/jazzband/django-auditlog/pull/262))
- Tracking of changes in many-to-many fields ([#309](https://github.com/jazzband/django-auditlog/pull/309))
#### Fixes
- Fix inconsistent changes with JSONField ([#355](https://github.com/jazzband/django-auditlog/pull/355))
- Disable `add` button in admin ui ([#378](https://github.com/jazzband/django-auditlog/pull/378))
- Fix n+1 query problem([#381](https://github.com/jazzband/django-auditlog/pull/381))
## 2.0.0 (2022-05-09)
#### Improvements
- feat: enable use of replica database (delegating the choice to `DATABASES_ROUTER`) ([#359](https://github.com/jazzband/django-auditlog/pull/359))
- Add `mask_fields` argument in `register` to mask sensitive information when logging ([#310](https://github.com/jazzband/django-auditlog/pull/310))
- Django: Drop 2.2 support. `django_jsonfield_backport` is not required anymore ([#370](https://github.com/jazzband/django-auditlog/pull/370))
- Remove `default_app_config` configuration ([#372](https://github.com/jazzband/django-auditlog/pull/372))
#### Important notes
- LogEntry no longer save to same database instance is using
## 1.0.0 (2022-01-24)
### Final
#### Improvements
- build: add classifiers for Python and Django
- build: replace django-jsonfield with django-jsonfield-backport ([#339](https://github.com/jazzband/django-auditlog/pull/339))
- ci: replace Travis with Github Actions
- docs: follow Jazzband guidelines (badge, how to contribute, code of conduct) ([#269](https://github.com/jazzband/django-auditlog/pull/269))
- docs: add a changelog
- docs: remove note about maintenance
- docs: update the release strategy
- docs: use the latest django LTS (3.2) to build docs
- feat: add a db index to `LogEntry`'s `action` field ([#236](https://github.com/jazzband/django-auditlog/pull/236))
- feat: add the content type to `resource` field
- feat: add the `actor` username to search fields in admin
- refactor: lint the code with Black and isort
- tests: init pre-commit config
- Python: add 3.9 and 3.10 support, drop 3.5 and 3.6 support
- Django: add 3.2 (LTS) and 4.0 support, drop 3.0 and 3.1 support
#### Fixes
- docs: replace `MIDDLEWARE_CLASSES` with `MIDDLEWARE`
- Remove old django (< 1.9) related codes
- Replace deprecated `smart_text()` with `smart_str()`
- Replace `ugettext` with `gettext` for Django 4
- Support Django's save method `update_fields` kwarg ([#336](https://github.com/jazzband/django-auditlog/pull/336))
- Fix invalid escape sequence on Python 3.7
### Alpha 1 (1.0a1, 2020-09-07)
#### Improvements
- Refactor the `auditlogflush` management command
- Clean up project structure
- Python: add 3.8 support, drop 2.7 and 3.4 support
- Django: add 3.0 and 3.1 support, drop 1.11, 2.0 and 2.1 support
#### Fixes
- Fix field choices diff
- Allow higher versions of python-dateutil than 2.6.0
## 0.4.8 (2019-11-12)
### Improvements
- Add support for PostgreSQL 10
## 0.4.7 (2019-12-19)
### Improvements
- Improve support multiple database (PostgreSQL, MySQL)
- Django: add 2.1 and 2.2 support, drop < 1.11 versions
- Python: add 3.7 support
## 0.4.6 (2018-09-18)
### Features
- Allow `AuditlogHistoryField` to block cascading deletes ([#172](https://github.com/jazzband/django-auditlog/pull/172))
### Improvements
- Add Python classifiers for supported Python versions ([#176](https://github.com/jazzband/django-auditlog/pull/176))
- Update README to include steps to release ([#185](https://github.com/jazzband/django-auditlog/pull/185))
### Fixes
- Fix the rendering of the `msg` field with Django 2.0 ([#166](https://github.com/jazzband/django-auditlog/pull/166))
- Mark `LogEntryAdminMixin` methods output as safe where required ([#167](https://github.com/jazzband/django-auditlog/pull/167))
## 0.4.5 (2018-01-12)
### Improvements
Added support for Django 2.0, along with a number of bug fixes.
## 0.4.4 (2017-11-17)
### Improvements
- Use [Tox](https://tox.wiki) to run tests
- Use Codecov to check to coverage before merging
- Django: drop 1.9 support, add 1.11 (LTS) support
- Python: tests against 2.7, 3.4, 3.5 and 3.6 versions
- Add `python-dateutil` to requirements
### Fixes
- Support models with UUID primary keys ([#111](https://github.com/jazzband/django-auditlog/pull/111))
- Add management commands package to setup.py ([#130](https://github.com/jazzband/django-auditlog/pull/130))
- Add `changes_display_dict` property to `LogEntry` model to display diff in a more human readable format ([#94](https://github.com/jazzband/django-auditlog/pull/94))
## 0.4.3 (2017-02-16)
### Fixes
- Fixes cricital bug in admin mixin making the library only usable on Django 1.11
## 0.4.2 (2017-02-16)
_As it turns out, haste is never good. Due to the focus on quickly releasing this version a nasty bug was not spotted, which makes this version only usable with Django 1.11 and above. Upgrading to 0.4.3 is not only encouraged but most likely necessary. Apologies for the inconvenience and lacking quality control._
### Improvements
- Models can be registered with decorators now
### Fixes
- A lot, yes, [_really_ a lot](https://github.com/jjkester/django-auditlog/milestone/8?closed=1), of fixes for the admin integration
- Flush command fixed for Django 1.10
## 0.4.1 (2016-12-27)
### Improvements
- Improved Django Admin pages
### Fixes
- Fixed multithreading issue where the wrong user was written to the log
## 0.4.0 (2016-08-17)
### Breaking changes
- Dropped support for Django 1.7
- Updated dependencies - _please check whether your project works with these higher versions_
### New features
- Management command for deleting all log entries
- Added admin interface (thanks, @crackjack)
### Improvements
- Django: add 1.10 support
### Fixes
- Solved migration error for MySQL users
## 0.3.3 (2016-01-23)
### Fixes
- fix `unregister` method
- `LogEntry.objects.get_for_objects` works properly on PostgreSQL
- Added index in 0.3.2 no longer breaks for users with MySQL databases
### Important notes
- The `object_pk` field is now limited to 255 chars
## 0.3.2 (2015-10-19)
### New functionality
- Django: support 1.9
### Improvements
- Enhanced performance for non-integer primary key lookups
## 0.3.1 (2015-07-29)
### Fixes
- Auditlog data is now correctly stored in the thread.
## 0.3.0 (2015-07-22)
### Breaking changes
- Django: drop out-of-date versions support, support 1.7+
- South is no longer supported
### New functionality
- Workaround for many-to-many support
- Additional data
- Python: support 2.7 and 3.4
### Improvements
- Better diffs
- Remote address is logged through middleware
- Better documentation
- Compatibility with [django-polymorphic](https://pypi.org/project/django-polymorphic/)
## 0.2.1 (2014-07-08)
### New functionality
- South compatibility for `AuditlogHistoryField`
## 0.2.0 (2014-03-08)
Although this release contains mostly bugfixes, the improvements were significant enough to justify a higher version number.
### Improvements
- Signal disconnection fixed
- Model diffs use unicode strings instead of regular strings
- Tests on middleware
## 0.1.1 (2013-12-12)
### New functionality
- Utility methods for using log entry data
### Improvements
- Only save a new log entry if there are actual changes
- Better way of loading the user model in the middleware
## 0.1.0 (2013-10-21)
First beta release of Auditlog.

9
CHANGES.rst Normal file
View file

@ -0,0 +1,9 @@
Changes
=======
Unreleased
----------
- Remove Django 3.0 support.
- Add Django 3.2 support.

View file

@ -1,46 +0,0 @@
# Code of Conduct
As contributors and maintainers of the Jazzband projects, and in the interest of
fostering an open and welcoming community, we pledge to respect all people who
contribute through reporting issues, posting feature requests, updating documentation,
submitting pull requests or patches, and other activities.
We are committed to making participation in the Jazzband a harassment-free experience
for everyone, regardless of the level of experience, gender, gender identity and
expression, sexual orientation, disability, personal appearance, body size, race,
ethnicity, age, religion, or nationality.
Examples of unacceptable behavior by participants include:
- The use of sexualized language or imagery
- Personal attacks
- Trolling or insulting/derogatory comments
- Public or private harassment
- Publishing other's private information, such as physical or electronic addresses,
without explicit permission
- Other unethical or unprofessional conduct
The Jazzband roadies have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are not
aligned to this Code of Conduct, or to ban temporarily or permanently any contributor
for other behaviors that they deem inappropriate, threatening, offensive, or harmful.
By adopting this Code of Conduct, the roadies commit themselves to fairly and
consistently applying these principles to every aspect of managing the jazzband
projects. Roadies who do not follow or enforce the Code of Conduct may be permanently
removed from the Jazzband roadies.
This code of conduct applies both within project spaces and in public spaces when an
individual is representing the project or its community.
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by
contacting the roadies at `roadies@jazzband.co`. All complaints will be reviewed and
investigated and will result in a response that is deemed necessary and appropriate to
the circumstances. Roadies are obligated to maintain confidentiality with regard to the
reporter of an incident.
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version
1.3.0, available at [https://contributor-covenant.org/version/1/3/0/][version]
[homepage]: https://contributor-covenant.org
[version]: https://contributor-covenant.org/version/1/3/0/

View file

@ -1,6 +1,7 @@
The MIT License (MIT)
Copyright (c) 2013-2020 Jan-Jelle Kester
Copyright (c) 2019-2020 Alieh Rymašeuski <alieh.rymasheuski@gmail.com>
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in

View file

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

View file

@ -1,42 +0,0 @@
# 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

@ -3,25 +3,24 @@ django-auditlog
[![Jazzband](https://jazzband.co/static/img/badge.svg)](https://jazzband.co/)
[![Build Status](https://github.com/jazzband/django-auditlog/workflows/Test/badge.svg)](https://github.com/jazzband/django-auditlog/actions)
[![Docs](https://readthedocs.org/projects/django-auditlog/badge/?version=latest)](https://django-auditlog.readthedocs.org/en/latest/?badge=latest)
[![Docs](https://readthedocs.org/projects/django-auditlog/badge/?version=latest)](http://django-auditlog.readthedocs.org/en/latest/?badge=latest)
[![codecov](https://codecov.io/gh/jazzband/django-auditlog/branch/master/graph/badge.svg)](https://codecov.io/gh/jazzband/django-auditlog)
[![Supported Python versions](https://img.shields.io/pypi/pyversions/django-auditlog.svg)](https://pypi.python.org/pypi/django-auditlog)
[![Supported Django versions](https://img.shields.io/pypi/djversions/django-auditlog.svg)](https://pypi.python.org/pypi/django-auditlog)
**Migrate to V3**
**Please remember that this app is still in development.**
**Test this app before deploying it in production environments.**
Check the [Upgrading to version 3](https://django-auditlog.readthedocs.io/en/latest/upgrade.html) doc before upgrading to V3.
```django-auditlog``` (Auditlog) is a reusable app for Django that makes logging object changes a breeze. Auditlog tries to use as much as Python and Django's built in functionality to keep the list of dependencies as short as possible. Also, Auditlog aims to be fast and simple to use.
```django-auditlog``` (Auditlog) is a reusable app for Django that makes logging object changes a breeze. Auditlog tries to use as much as Python and Djangos built in functionality to keep the list of dependencies as short as possible. Also, Auditlog aims to be fast and simple to use.
Auditlog is created out of the need for a simple Django app that logs changes to models along with the user who made the changes (later referred to as actor). Existing solutions seemed to offer a type of version control, which was found excessive and expensive in terms of database storage and performance.
The core idea of Auditlog is similar to the log from Django's admin. Unlike the log from Django's admin (```django.contrib.admin```) Auditlog is much more flexible. Also, Auditlog saves a summary of the changes in JSON format, so changes can be tracked easily.
The core idea of Auditlog is similar to the log from Djangos admin. Unlike the log from Djangos admin (```django.contrib.admin```) Auditlog is much more flexible. Also, Auditlog saves a summary of the changes in JSON format, so changes can be tracked easily.
Documentation
-------------
The documentation for ```django-auditlog``` can be found on https://django-auditlog.readthedocs.org. The source files are available in the ```docs``` folder.
The documentation for ```django-auditlog``` can be found on http://django-auditlog.readthedocs.org. The source files are available in the ```docs``` folder.
License
-------
@ -36,10 +35,10 @@ If you have great ideas for Auditlog, or if you like to improve something, feel
Releases
--------
1. Make sure all tests on `master` are green
2. Create a new branch `vX.Y.Z` from master for that specific release
3. Update the CHANGELOG release date
4. Pull request `vX.Y.Z` -> `master`
5. As a project lead, once the PR is merged, create and push a tag `vX.Y.Z`: this will trigger the release build and a notification will be sent from Jazzband of the availability of two packages (tgz and wheel)
6. Test the install
7. Publish the release to PyPI
1. Make sure all tests on `master` are green.
2. Create a new branch `vX.Y.Z` from master for that specific release.
3. Bump versions in `setup.py` and `docs/source/conf.py` (docs have 2 places where the versions need to be changed!)
4. Pull request `vX.Y.Z` -> `stable`. Merging policy is very strict. This triggers a new release.
5. Pull request `stable` -> `master`. Now everything is back in sync.
Opening a pull request from `master` directly to `stable` is discouraged as `master` may be updated while the PR is open, thus changing the contents of the release.

View file

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

View file

@ -1,62 +1,54 @@
from functools import cached_property
from django.conf import settings
from django.contrib import admin
from django.contrib.auth import get_user_model
from django.utils.translation import gettext_lazy as _
from django.core.paginator import Paginator
from django.utils.functional import cached_property
from auditlog import get_logentry_model
from auditlog.filters import CIDFilter, ResourceTypeFilter
from auditlog.count import limit_query_time
from auditlog.filters import (
FieldFilter,
ResourceTypeFilter,
ShortActorFilter,
get_timestamp_filter,
)
from auditlog.mixins import LogEntryAdminMixin
LogEntry = get_logentry_model()
from auditlog.models import LogEntry
class TimeLimitedPaginator(Paginator):
"""A PostgreSQL-specific paginator with a hard time limit for total count of pages."""
@cached_property
@limit_query_time(
getattr(settings, "AUDITLOG_PAGINATOR_TIMEOUT", 500), default=100000
)
def count(self):
return super().count
@admin.register(LogEntry)
class LogEntryAdmin(admin.ModelAdmin, LogEntryAdminMixin):
date_hierarchy = "timestamp"
list_select_related = ["content_type", "actor"]
list_display = [
"created",
"resource_url",
"action",
"msg_short",
"user_url",
"cid_url",
]
list_display = ["created", "resource_url", "action", "msg_short", "user_url"]
search_fields = [
"timestamp",
"object_repr",
"changes",
"actor__first_name",
"actor__last_name",
f"actor__{get_user_model().USERNAME_FIELD}",
]
list_filter = ["action", ResourceTypeFilter, CIDFilter]
list_filter = [
"action",
ShortActorFilter,
ResourceTypeFilter,
FieldFilter,
("timestamp", get_timestamp_filter()),
]
readonly_fields = ["created", "resource_url", "action", "user_url", "msg"]
fieldsets = [
(None, {"fields": ["created", "user_url", "resource_url", "cid"]}),
(_("Changes"), {"fields": ["action", "msg"]}),
(None, {"fields": ["created", "user_url", "resource_url"]}),
("Changes", {"fields": ["action", "msg"]}),
]
list_select_related = ["actor", "content_type"]
show_full_result_count = False
paginator = TimeLimitedPaginator
def has_add_permission(self, request):
return False
def has_change_permission(self, request, obj=None):
return False
@cached_property
def _own_url_names(self):
return [pattern.name for pattern in self.urls if pattern.name]
def has_delete_permission(self, request, obj=None):
if (
request.resolver_match
and request.resolver_match.url_name not in self._own_url_names
):
# only allow cascade delete to satisfy delete_related flag
return super().has_delete_permission(request, obj)
return False
def get_queryset(self, request):
self.request = request
return super().get_queryset(request=request)
admin.site.register(LogEntry, LogEntryAdmin)

View file

@ -1,17 +1,6 @@
from django.apps import AppConfig
from django.utils.translation import gettext_lazy as _
class AuditlogConfig(AppConfig):
name = "auditlog"
verbose_name = _("Audit log")
default_auto_field = "django.db.models.AutoField"
def ready(self):
from auditlog.registry import auditlog
auditlog.register_from_settings()
from auditlog import models
models.changes_func = models._changes_func()
verbose_name = "Audit log"

View file

@ -1,70 +0,0 @@
from contextvars import ContextVar
from django.conf import settings
from django.http import HttpRequest
from django.utils.module_loading import import_string
correlation_id = ContextVar("auditlog_correlation_id", default=None)
def set_cid(request: HttpRequest | None = None) -> None:
"""
A function to read the cid from a request.
If the header is not in the request, then we set it to `None`.
Note: we look for the value of `AUDITLOG_CID_HEADER` in `request.headers` and in `request.META`.
This function doesn't do anything if the user is supplying their own `AUDITLOG_CID_GETTER`.
:param request: The request to get the cid from.
:return: None
"""
if settings.AUDITLOG_CID_GETTER:
return
cid = None
header = settings.AUDITLOG_CID_HEADER
if header and request:
if header in request.headers:
cid = request.headers.get(header)
elif header in request.META:
cid = request.META.get(header)
# Ideally, this line should be nested inside the if statement.
# However, because the tests do not run requests in multiple threads,
# we have to always set the value of the cid,
# even if the request does not have the header present,
# in which case it will be set to None
correlation_id.set(cid)
def _get_cid() -> str | None:
return correlation_id.get()
def get_cid() -> str | None:
"""
Calls the cid getter function based on `settings.AUDITLOG_CID_GETTER`
If the setting value is:
* None: then it calls the default getter (which retrieves the value set in `set_cid`)
* callable: then it calls the function
* type(str): then it imports the function and then call it
The result is then returned to the caller.
If your custom getter does not depend on `set_header()`,
then we recommend setting `settings.AUDITLOG_CID_GETTER` to `None`.
:return: The correlation ID
"""
method = settings.AUDITLOG_CID_GETTER
if not method:
return _get_cid()
if callable(method):
return method()
return import_string(method)()

View file

@ -1,78 +0,0 @@
from django.conf import settings
# Register all models when set to True
settings.AUDITLOG_INCLUDE_ALL_MODELS = getattr(
settings, "AUDITLOG_INCLUDE_ALL_MODELS", False
)
# Exclude models in registration process
# It will be considered when `AUDITLOG_INCLUDE_ALL_MODELS` is True
settings.AUDITLOG_EXCLUDE_TRACKING_MODELS = getattr(
settings, "AUDITLOG_EXCLUDE_TRACKING_MODELS", ()
)
# Register models and define their logging behaviour
settings.AUDITLOG_INCLUDE_TRACKING_MODELS = getattr(
settings, "AUDITLOG_INCLUDE_TRACKING_MODELS", ()
)
# Exclude named fields across all models
settings.AUDITLOG_EXCLUDE_TRACKING_FIELDS = getattr(
settings, "AUDITLOG_EXCLUDE_TRACKING_FIELDS", ()
)
# Mask named fields across all models
settings.AUDITLOG_MASK_TRACKING_FIELDS = getattr(
settings, "AUDITLOG_MASK_TRACKING_FIELDS", ()
)
# Disable on raw save to avoid logging imports and similar
settings.AUDITLOG_DISABLE_ON_RAW_SAVE = getattr(
settings, "AUDITLOG_DISABLE_ON_RAW_SAVE", False
)
# CID
settings.AUDITLOG_CID_HEADER = getattr(
settings, "AUDITLOG_CID_HEADER", "x-correlation-id"
)
settings.AUDITLOG_CID_GETTER = getattr(settings, "AUDITLOG_CID_GETTER", None)
# migration
settings.AUDITLOG_TWO_STEP_MIGRATION = getattr(
settings, "AUDITLOG_TWO_STEP_MIGRATION", False
)
settings.AUDITLOG_USE_TEXT_CHANGES_IF_JSON_IS_NOT_PRESENT = getattr(
settings, "AUDITLOG_USE_TEXT_CHANGES_IF_JSON_IS_NOT_PRESENT", False
)
# Disable remote_addr field in database
settings.AUDITLOG_DISABLE_REMOTE_ADDR = getattr(
settings, "AUDITLOG_DISABLE_REMOTE_ADDR", False
)
# Number of characters at which changes_display_dict property should be shown
settings.AUDITLOG_CHANGE_DISPLAY_TRUNCATE_LENGTH = getattr(
settings, "AUDITLOG_CHANGE_DISPLAY_TRUNCATE_LENGTH", 140
)
# Use pure JSON for changes field
settings.AUDITLOG_STORE_JSON_CHANGES = getattr(
settings, "AUDITLOG_STORE_JSON_CHANGES", False
)
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

@ -1,107 +1,67 @@
import contextlib
import threading
import time
from contextvars import ContextVar
from functools import partial
from django.contrib.auth import get_user_model
from django.db.models.signals import pre_save
from auditlog import get_logentry_model
from auditlog.models import LogEntry
auditlog_value = ContextVar("auditlog_value")
auditlog_disabled = ContextVar("auditlog_disabled", default=False)
threadlocal = threading.local()
@contextlib.contextmanager
def set_actor(actor, remote_addr=None, remote_port=None):
context_data = {
"actor": actor,
"remote_addr": remote_addr,
"remote_port": remote_port,
}
return call_context_manager(context_data)
@contextlib.contextmanager
def set_extra_data(context_data):
return call_context_manager(context_data)
def call_context_manager(context_data):
def set_actor(actor, remote_addr=None):
"""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)
threadlocal.auditlog = {
"signal_duid": ("set_actor", time.time()),
"remote_addr": remote_addr,
}
# Connect signal for automatic logging
set_extra_data = partial(
_set_extra_data,
signal_duid=context_data["signal_duid"],
set_actor = partial(
_set_actor, user=actor, signal_duid=threadlocal.auditlog["signal_duid"]
)
pre_save.connect(
set_extra_data,
set_actor,
sender=LogEntry,
dispatch_uid=context_data["signal_duid"],
dispatch_uid=threadlocal.auditlog["signal_duid"],
weak=False,
)
try:
yield
finally:
try:
auditlog = auditlog_value.get()
except LookupError:
auditlog = threadlocal.auditlog
except AttributeError:
pass
else:
pre_save.disconnect(sender=LogEntry, dispatch_uid=auditlog["signal_duid"])
del threadlocal.auditlog
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):
def _set_actor(user, sender, instance, signal_duid, **kwargs):
"""Signal receiver with extra 'user' and 'signal_duid' kwargs.
This function becomes a valid signal receiver when it is curried with the actor and a dispatch id.
"""
LogEntry = get_logentry_model()
try:
auditlog = auditlog_value.get()
except LookupError:
auditlog = threadlocal.auditlog
except AttributeError:
pass
else:
if signal_duid != auditlog["signal_duid"]:
return
auth_user_model = get_user_model()
if (
sender == LogEntry
and isinstance(user, auth_user_model)
and instance.actor is None
):
instance.actor = user
_set_actor(auditlog, instance, sender)
for key in auditlog:
if key != "actor" and hasattr(LogEntry, key):
if callable(auditlog[key]):
setattr(instance, key, auditlog[key]())
else:
setattr(instance, key, auditlog[key])
@contextlib.contextmanager
def disable_auditlog():
token = auditlog_disabled.set(True)
try:
yield
finally:
try:
auditlog_disabled.reset(token)
except LookupError:
pass
instance.remote_addr = auditlog["remote_addr"]

23
auditlog/count.py Normal file
View file

@ -0,0 +1,23 @@
from django.db import OperationalError, connection, transaction
def limit_query_time(timeout, default=None):
"""A PostgreSQL-specific decorator with a hard time limit and a default return value.
Timeout in milliseconds.
Courtesy of https://medium.com/@hakibenita/optimizing-django-admin-paginator-53c4eb6bfca3
"""
def decorator(function):
def _limit_query_time(*args, **kwargs):
with transaction.atomic(), connection.cursor() as cursor:
cursor.execute("SET LOCAL statement_timeout TO %s;", (timeout,))
try:
return function(*args, **kwargs)
except OperationalError:
return default
return _limit_query_time
return decorator

View file

@ -1,15 +1,8 @@
import json
from collections.abc import Callable
from datetime import timezone
from django.conf import settings
from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist
from django.db.models import NOT_PROVIDED, DateTimeField, ForeignKey, JSONField, Model
from django.utils import timezone as django_timezone
from django.core.exceptions import ObjectDoesNotExist
from django.db.models import NOT_PROVIDED, DateTimeField, Model
from django.utils import timezone
from django.utils.encoding import smart_str
from django.utils.module_loading import import_string
from auditlog import get_logentry_model
def track_field(field):
@ -23,6 +16,7 @@ def track_field(field):
:return: Whether the given field should be tracked.
:rtype: bool
"""
from auditlog.models import LogEntry
# Do not track many to many relations
if field.many_to_many:
@ -31,7 +25,7 @@ def track_field(field):
# Do not track relations to LogEntry
if (
getattr(field, "remote_field", None) is not None
and field.remote_field.model == get_logentry_model()
and field.remote_field.model == LogEntry
):
return False
@ -40,8 +34,8 @@ def track_field(field):
def get_fields_in_model(instance):
"""
Returns the list of fields in the given model instance. Checks whether to use the official
_meta API or use the raw data. This method excludes many to many fields.
Returns the list of fields in the given model instance. Checks whether to use the official _meta API or use the raw
data. This method excludes many to many fields.
:param instance: The model instance to get the fields for
:type instance: Model
@ -53,7 +47,7 @@ def get_fields_in_model(instance):
return [f for f in instance._meta.get_fields() if track_field(f)]
def get_field_value(obj, field, use_json_for_changes=False):
def get_field_value(obj, field):
"""
Gets the value of a given model instance field.
@ -64,136 +58,35 @@ def get_field_value(obj, field, use_json_for_changes=False):
:return: The value of the field as a string.
:rtype: str
"""
def get_default_value():
"""
Attempts to get the default value for a field from the model's field definition.
:return: The default value of the field or None
"""
if isinstance(field, DateTimeField):
# DateTimeFields are timezone-aware, so we need to convert the field
# to its naive form before we can accurately compare them for changes.
try:
model_field = obj._meta.get_field(field.name)
default = model_field.default
except (AttributeError, FieldDoesNotExist):
default = NOT_PROVIDED
if default is NOT_PROVIDED:
default = None
elif callable(default):
default = default()
return smart_str(default) if not use_json_for_changes else default
try:
if isinstance(field, DateTimeField):
# DateTimeFields are timezone-aware, so we need to convert the field
# to its naive form before we can accurately compare them for changes.
value = getattr(obj, field.name)
try:
value = field.to_python(value)
except TypeError:
return value
if (
value is not None
and settings.USE_TZ
and not django_timezone.is_naive(value)
):
value = django_timezone.make_naive(value, timezone=timezone.utc)
elif isinstance(field, JSONField):
value = field.to_python(getattr(obj, field.name))
if not use_json_for_changes:
try:
value = json.dumps(value, sort_keys=True, cls=field.encoder)
except TypeError:
pass
elif (
not settings.AUDITLOG_USE_FK_STRING_REPRESENTATION
and (field.one_to_one or field.many_to_one)
and hasattr(field, "rel_class")
):
value = smart_str(getattr(obj, field.get_attname()), strings_only=True)
else:
value = getattr(obj, field.name)
if not use_json_for_changes:
value = smart_str(value)
if type(value).__name__ == "__proxy__":
value = str(value)
except (ObjectDoesNotExist, AttributeError):
return get_default_value()
value = field.to_python(getattr(obj, field.name, None))
if value is not None and settings.USE_TZ and not timezone.is_naive(value):
value = timezone.make_naive(value, timezone=timezone.utc)
except ObjectDoesNotExist:
value = field.default if field.default is not NOT_PROVIDED else None
else:
try:
value = smart_str(getattr(obj, field.name, None))
except ObjectDoesNotExist:
value = field.default if field.default is not NOT_PROVIDED else None
return value
def is_primitive(obj) -> bool:
def model_instance_diff(old, new):
"""
Checks if the given object is a primitive Python type that can be safely serialized to JSON.
:param obj: The object to check
:return: True if the object is a primitive type, False otherwise
:rtype: bool
"""
primitive_types = (type(None), bool, int, float, str, list, tuple, dict, set)
return isinstance(obj, primitive_types)
def get_mask_function(mask_callable: str | None = None) -> Callable[[str], str]:
"""
Get the masking function to use based on the following priority:
1. Model-specific mask_callable if provided
2. mask_callable from settings if configured
3. Default mask_str function
:param mask_callable: The dotted path to a callable that will be used for masking.
:type mask_callable: str
:return: A callable that takes a string and returns a masked version.
:rtype: Callable[[str], str]
"""
if mask_callable:
return import_string(mask_callable)
default_mask_callable = settings.AUDITLOG_MASK_CALLABLE
if default_mask_callable:
return import_string(default_mask_callable)
return mask_str
def mask_str(value: str) -> str:
"""
Masks the first half of the input string to remove sensitive data.
:param value: The value to mask.
:type value: str
:return: The masked version of the string.
:rtype: str
"""
mask_limit = int(len(value) / 2)
return "*" * mask_limit + value[mask_limit:]
def model_instance_diff(
old: Model | None,
new: Model | None,
fields_to_check=None,
use_json_for_changes=False,
):
"""
Calculates the differences between two model instances. One of the instances may be ``None``
(i.e., a newly created model or deleted model). This will cause all fields with a value to have
changed (from ``None``).
Calculates the differences between two model instances. One of the instances may be ``None`` (i.e., a newly
created model or deleted model). This will cause all fields with a value to have changed (from ``None``).
:param old: The old state of the model instance.
:type old: Model
:param new: The new state of the model instance.
:type new: Model
:param fields_to_check: An iterable of the field names to restrict the diff to, while ignoring the rest of
the model's fields. This is used to pass the `update_fields` kwarg from the model's `save` method.
:param use_json_for_changes: whether or not to use a JSON for changes
(see settings.AUDITLOG_STORE_JSON_CHANGES)
:type fields_to_check: Iterable
:return: A dictionary with the names of the changed fields as keys and a two tuple of the old and new
field values as value.
:return: A dictionary with the names of the changed fields as keys and a two tuple of the old and new field values
as value.
:rtype: dict
"""
from auditlog.registry import auditlog
@ -218,16 +111,6 @@ def model_instance_diff(
fields = set()
model_fields = None
if fields_to_check:
fields = {
field
for field in fields
if (
(isinstance(field, ForeignKey) and field.attname in fields_to_check)
or (field.name in fields_to_check)
)
}
# Check if fields must be filtered
if (
model_fields
@ -252,30 +135,11 @@ def model_instance_diff(
fields = filtered_fields
for field in fields:
old_value = get_field_value(old, field, use_json_for_changes)
new_value = get_field_value(new, field, use_json_for_changes)
old_value = get_field_value(old, field)
new_value = get_field_value(new, field)
if old_value != new_value:
if model_fields and field.name in model_fields["mask_fields"]:
mask_func = get_mask_function(model_fields.get("mask_callable"))
diff[field.name] = (
mask_func(smart_str(old_value)),
mask_func(smart_str(new_value)),
)
else:
if not use_json_for_changes:
diff[field.name] = (smart_str(old_value), smart_str(new_value))
else:
# TODO: should we handle the case where the value is a django Model specifically?
# for example, could create a list of ids for ManyToMany fields
# this maintains the behavior of the original code
if not is_primitive(old_value):
old_value = smart_str(old_value)
if not is_primitive(new_value):
new_value = smart_str(new_value)
diff[field.name] = (old_value, new_value)
diff[field.name] = (smart_str(old_value), smart_str(new_value))
if len(diff) == 0:
diff = None

View file

@ -1,15 +1,47 @@
from django.apps import apps
from django.contrib.admin import SimpleListFilter
from django.utils.translation import gettext_lazy as _
from django.contrib.admin.filters import DateFieldListFilter
from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.fields import JSONField
from django.db import connection
from django.db.models import Value
from django.db.models.functions import Cast, Concat
from auditlog.registry import auditlog
class ShortActorFilter(SimpleListFilter):
title = "Actor"
parameter_name = "actor"
def lookups(self, request, model_admin):
return [("null", "System"), ("not_null", "Users")]
def queryset(self, request, queryset):
value = self.value()
if value is None:
return queryset
if value == "null":
return queryset.filter(actor__isnull=True)
return queryset.filter(actor__isnull=False)
class ResourceTypeFilter(SimpleListFilter):
title = _("Resource Type")
title = "Resource Type"
parameter_name = "resource_type"
def lookups(self, request, model_admin):
qs = model_admin.get_queryset(request)
types = qs.values_list("content_type_id", "content_type__model")
return list(types.order_by("content_type__model").distinct())
tracked_model_names = [
"{}.{}".format(m._meta.app_label, m._meta.model_name)
for m in auditlog.get_models()
]
model_name_concat = Concat("app_label", Value("."), "model")
content_types = ContentType.objects.annotate(
model_name=model_name_concat,
).filter(
model_name__in=tracked_model_names,
)
return content_types.order_by("model_name").values_list("id", "model_name")
def queryset(self, request, queryset):
if self.value() is None:
@ -17,17 +49,50 @@ class ResourceTypeFilter(SimpleListFilter):
return queryset.filter(content_type_id=self.value())
class CIDFilter(SimpleListFilter):
title = _("Correlation ID")
parameter_name = "cid"
class FieldFilter(SimpleListFilter):
title = "Field"
parameter_name = "field"
parent = ResourceTypeFilter
def __init__(self, request, *args, **kwargs):
self.target_model = self._get_target_model(request)
super().__init__(request, *args, **kwargs)
def _get_target_model(self, request):
# the parameters consumed by previous filters aren't passed to subsequent filters,
# so we have to look into the request parameters explicitly
content_type_id = request.GET.get(self.parent.parameter_name)
if not content_type_id:
return None
return ContentType.objects.get(id=content_type_id).model_class()
def lookups(self, request, model_admin):
return []
def has_output(self):
return True
if connection.vendor != "postgresql":
# filtering inside JSON is PostgreSQL-specific for now
return []
if not self.target_model:
return []
return sorted(
(field.name, field.name) for field in self.target_model._meta.fields
)
def queryset(self, request, queryset):
if self.value() is None:
return queryset
return queryset.filter(cid=self.value())
return queryset.annotate(changes_json=Cast("changes", JSONField())).filter(
**{"changes_json__{}__isnull".format(self.value()): False}
)
def get_timestamp_filter():
"""Returns rangefilter filter class if able or a simple list filter as a fallback."""
if apps.is_installed("rangefilter"):
try:
from rangefilter.filter import DateTimeRangeFilter
return DateTimeRangeFilter
except ImportError:
pass
return DateFieldListFilter

View file

@ -1,192 +0,0 @@
# 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] "ログエントリ"

View file

@ -1,192 +0,0 @@
# 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

@ -1,11 +1,6 @@
import datetime
from django.core.management.base import BaseCommand
from django.db import connection
from auditlog import get_logentry_model
LogEntry = get_logentry_model()
from auditlog.models import LogEntry
class Command(BaseCommand):
@ -13,84 +8,27 @@ class Command(BaseCommand):
def add_arguments(self, parser):
parser.add_argument(
"-y",
"--yes",
"-y, --yes",
action="store_true",
default=None,
help="Continue without asking confirmation.",
dest="yes",
)
parser.add_argument(
"-b",
"--before-date",
default=None,
help="Flush all entries with a timestamp before a given date (ISO 8601).",
dest="before_date",
type=datetime.date.fromisoformat,
)
parser.add_argument(
"-t",
"--truncate",
action="store_true",
default=None,
help="Truncate log entry table.",
dest="truncate",
)
def handle(self, *args, **options):
answer = options["yes"]
truncate = options["truncate"]
before = options["before_date"]
if truncate and before:
self.stdout.write(
"Truncate deletes all log entries and can not be passed with before-date."
)
return
if answer is None:
warning_message = (
self.stdout.write(
"This action will clear all log entries from the database."
)
if before is not None:
warning_message = f"This action will clear all log entries before {before} from the database."
self.stdout.write(warning_message)
response = (
input("Are you sure you want to continue? [y/N]: ").lower().strip()
)
answer = response == "y"
if not answer:
self.stdout.write("Aborted.")
return
if not truncate:
entries = LogEntry.objects.all()
if before is not None:
entries = entries.filter(timestamp__date__lt=before)
count, _ = entries.delete()
if answer:
count, _ = LogEntry.objects.all().delete()
self.stdout.write("Deleted %d objects." % count)
else:
database_vendor = connection.vendor
database_display_name = connection.display_name
table_name = LogEntry._meta.db_table
if not TruncateQuery.support_truncate_statement(database_vendor):
self.stdout.write(
"Database %s does not support truncate statement."
% database_display_name
)
return
with connection.cursor() as cursor:
query = TruncateQuery.to_sql(table_name)
cursor.execute(query)
self.stdout.write("Truncated log entry table.")
class TruncateQuery:
SUPPORTED_VENDORS = ("postgresql", "mysql", "oracle", "microsoft")
@classmethod
def support_truncate_statement(cls, database_vendor) -> bool:
return database_vendor in cls.SUPPORTED_VENDORS
@staticmethod
def to_sql(table_name) -> str:
return f"TRUNCATE TABLE {table_name};"
self.stdout.write("Aborted.")

View file

@ -1,144 +0,0 @@
from math import ceil
from django.conf import settings
from django.core.management import CommandError, CommandParser
from django.core.management.base import BaseCommand
from auditlog import get_logentry_model
LogEntry = get_logentry_model()
class Command(BaseCommand):
help = "Migrates changes from changes_text to json changes."
requires_migrations_checks = True
def add_arguments(self, parser: CommandParser):
group = parser.add_argument_group()
group.add_argument(
"--check",
action="store_true",
help="Just check the status of the migration",
dest="check",
)
group.add_argument(
"-d",
"--database",
default=None,
metavar="The database engine",
help="If provided, the script will use native db operations. "
"Otherwise, it will use LogEntry.objects.bulk_update",
dest="db",
type=str,
choices=["postgres", "mysql", "oracle"],
)
group.add_argument(
"-b",
"--batch-size",
default=500,
help="Split the migration into multiple batches. If 0, then no batching will be done. "
"When passing a -d/database, the batch value will be ignored.",
dest="batch_size",
type=int,
)
def handle(self, *args, **options):
database = options["db"]
batch_size = options["batch_size"]
check = options["check"]
if (not self.check_logs()) or check:
return
if database:
result = self.migrate_using_sql(database)
self.stdout.write(
self.style.SUCCESS(
f"Updated {result} records using native database operations."
)
)
else:
result = self.migrate_using_django(batch_size)
self.stdout.write(
self.style.SUCCESS(f"Updated {result} records using django operations.")
)
self.check_logs()
def check_logs(self):
count = self.get_logs().count()
if count:
self.stdout.write(f"There are {count} records that needs migration.")
return True
self.stdout.write(self.style.SUCCESS("All records have been migrated."))
if settings.AUDITLOG_USE_TEXT_CHANGES_IF_JSON_IS_NOT_PRESENT:
var_msg = self.style.WARNING(
"AUDITLOG_USE_TEXT_CHANGES_IF_JSON_IS_NOT_PRESENT"
)
self.stdout.write(f"You can now set {var_msg} to False.")
return False
def get_logs(self):
return LogEntry.objects.filter(
changes_text__isnull=False, changes__isnull=True
).exclude(changes_text__exact="")
def migrate_using_django(self, batch_size):
def _apply_django_migration(_logs) -> int:
import json
updated = []
errors = []
for log in _logs:
try:
log.changes = json.loads(log.changes_text)
except ValueError:
errors.append(log.id)
else:
updated.append(log)
LogEntry.objects.bulk_update(updated, fields=["changes"])
if errors:
self.stderr.write(
self.style.ERROR(
f"ValueError was raised while converting the logs with these ids into json."
f"They where not be included in this migration batch."
f"\n"
f"{errors}"
)
)
return len(updated)
logs = self.get_logs()
if not batch_size:
return _apply_django_migration(logs)
total_updated = 0
for _ in range(ceil(logs.count() / batch_size)):
total_updated += _apply_django_migration(self.get_logs()[:batch_size])
return total_updated
def migrate_using_sql(self, database):
from django.db import connection
def postgres():
with connection.cursor() as cursor:
cursor.execute(f"""
UPDATE {LogEntry._meta.db_table}
SET changes="changes_text"::jsonb
WHERE changes_text IS NOT NULL
AND changes_text <> ''
AND changes IS NULL
""")
return cursor.cursor.rowcount
if database == "postgres":
return postgres()
raise CommandError(
f"Migrating the records using {database} is not implemented. "
f"Run this management command without passing a -d/--database argument."
)

View file

@ -1,70 +1,35 @@
from django.conf import settings
from django.contrib.auth import get_user_model
import contextlib
from auditlog.cid import set_cid
from auditlog.context import set_extra_data
from auditlog.context import set_actor
class AuditlogMiddleware:
@contextlib.contextmanager
def nullcontext():
"""Equivalent to contextlib.nullcontext(None) from Python 3.7."""
yield
class AuditlogMiddleware(object):
"""
Middleware to couple the request's user to log items. This is accomplished by currying the
signal receiver with the user from the request (or None if the user is not authenticated).
Middleware to couple the request's user to log items. This is accomplished by currying the signal receiver with the
user from the request (or None if the user is not authenticated).
"""
def __init__(self, get_response=None):
self.get_response = get_response
if not isinstance(settings.AUDITLOG_DISABLE_REMOTE_ADDR, bool):
raise TypeError("Setting 'AUDITLOG_DISABLE_REMOTE_ADDR' must be a boolean")
@staticmethod
def _get_remote_addr(request):
if settings.AUDITLOG_DISABLE_REMOTE_ADDR:
return None
# In case there is no proxy, return the original address
if not request.headers.get("X-Forwarded-For"):
return request.META.get("REMOTE_ADDR")
# In case of proxy, set 'original' address
remote_addr: str = request.headers.get("X-Forwarded-For").split(",")[0]
# Remove port number from remote_addr
if "." in remote_addr and ":" in remote_addr: # IPv4 with port (`x.x.x.x:x`)
remote_addr = remote_addr.split(":")[0]
elif "[" in remote_addr: # IPv6 with port (`[:::]:x`)
remote_addr = remote_addr[1:].split("]")[0]
return remote_addr
@staticmethod
def _get_remote_port(request) -> int | None:
remote_port = request.headers.get("X-Forwarded-Port", "")
try:
remote_port = int(remote_port)
except ValueError:
remote_port = None
return remote_port
@staticmethod
def _get_actor(request):
user = getattr(request, "user", None)
if isinstance(user, get_user_model()) and user.is_authenticated:
return user
return None
def get_extra_data(self, request):
context_data = {}
context_data["remote_addr"] = self._get_remote_addr(request)
context_data["remote_port"] = self._get_remote_port(request)
context_data["actor"] = self._get_actor(request)
return context_data
def __call__(self, request):
set_cid(request)
with set_extra_data(context_data=self.get_extra_data(request)):
if request.META.get("HTTP_X_FORWARDED_FOR"):
# In case of proxy, set 'original' address
remote_addr = request.META.get("HTTP_X_FORWARDED_FOR").split(",")[0]
else:
remote_addr = request.META.get("REMOTE_ADDR")
if hasattr(request, "user") and request.user.is_authenticated:
context = set_actor(actor=request.user, remote_addr=remote_addr)
else:
context = nullcontext()
with context:
return self.get_response(request)

View file

@ -4,6 +4,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("contenttypes", "0001_initial"),

View file

@ -2,6 +2,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("auditlog", "0001_initial"),
]

View file

@ -2,6 +2,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("auditlog", "0002_auto_support_long_primary_keys"),
]

View file

@ -1,7 +1,9 @@
from django.db import migrations, models
import jsonfield.fields
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("auditlog", "0003_logentry_remote_addr"),
]
@ -10,6 +12,6 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name="logentry",
name="additional_data",
field=models.JSONField(null=True, blank=True),
field=jsonfield.fields.JSONField(null=True, blank=True),
),
]

View file

@ -1,7 +1,9 @@
from django.db import migrations, models
import jsonfield.fields
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("auditlog", "0004_logentry_detailed_object_repr"),
]
@ -10,7 +12,7 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="logentry",
name="additional_data",
field=models.JSONField(
field=jsonfield.fields.JSONField(
null=True, verbose_name="additional data", blank=True
),
),

View file

@ -2,6 +2,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("auditlog", "0005_logentry_additional_data_verbose_name"),
]

View file

@ -2,6 +2,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("auditlog", "0006_object_pk_index"),
]

View file

@ -1,19 +0,0 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("auditlog", "0007_object_pk_type"),
]
operations = [
migrations.AlterField(
model_name="logentry",
name="action",
field=models.PositiveSmallIntegerField(
choices=[(0, "create"), (1, "update"), (2, "delete")],
db_index=True,
verbose_name="action",
),
),
]

View file

@ -1,11 +1,11 @@
# Generated by Django 4.0.3 on 2022-03-11 23:16
# -*- coding: utf-8 -*-
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("auditlog", "0009_alter_logentry_additional_data"),
("auditlog", "0007_object_pk_type"),
]
operations = [

View file

@ -1,17 +0,0 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("auditlog", "0008_action_index"),
]
operations = [
migrations.AlterField(
model_name="logentry",
name="additional_data",
field=models.JSONField(
blank=True, null=True, verbose_name="additional data"
),
),
]

View file

@ -0,0 +1,21 @@
# -*- coding: utf-8 -*-
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("auditlog", "0008_timestamp_index"),
]
operations = [
migrations.AlterIndexTogether(
name="logentry",
index_together={("timestamp", "id")},
),
migrations.AlterField(
model_name="logentry",
name="timestamp",
field=models.DateTimeField(auto_now_add=True, verbose_name="timestamp"),
),
]

View file

@ -1,17 +0,0 @@
# Generated by Django 4.0 on 2022-08-05 19:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("auditlog", "0010_alter_logentry_timestamp"),
]
operations = [
migrations.AddField(
model_name="logentry",
name="serialized_data",
field=models.JSONField(null=True),
),
]

View file

@ -1,21 +0,0 @@
# Generated by Django 4.1.1 on 2022-10-13 07:56
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("auditlog", "0011_logentry_serialized_data"),
]
operations = [
migrations.AlterField(
model_name="logentry",
name="action",
field=models.PositiveSmallIntegerField(
choices=[(0, "create"), (1, "update"), (2, "delete"), (3, "access")],
db_index=True,
verbose_name="action",
),
),
]

View file

@ -1,22 +0,0 @@
# Generated by Django 4.1.4 on 2022-12-15 21:24
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("auditlog", "0012_add_logentry_action_access"),
]
operations = [
migrations.AlterField(
model_name="logentry",
name="timestamp",
field=models.DateTimeField(
db_index=True,
default=django.utils.timezone.now,
verbose_name="timestamp",
),
),
]

View file

@ -1,23 +0,0 @@
# Generated by Django 4.1.4 on 2022-12-18 13:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("auditlog", "0013_alter_logentry_timestamp"),
]
operations = [
migrations.AddField(
model_name="logentry",
name="cid",
field=models.CharField(
blank=True,
db_index=True,
max_length=255,
null=True,
verbose_name="Correlation ID",
),
),
]

View file

@ -1,40 +0,0 @@
# Generated by Django 4.0 on 2022-08-04 15:41
from django.conf import settings
from django.db import migrations, models
def two_step_migrations() -> list:
if settings.AUDITLOG_TWO_STEP_MIGRATION:
return [
migrations.RenameField(
model_name="logentry",
old_name="changes",
new_name="changes_text",
),
migrations.AddField(
model_name="logentry",
name="changes",
field=models.JSONField(null=True, verbose_name="change message"),
),
]
return [
migrations.AddField(
model_name="logentry",
name="changes_text",
field=models.TextField(blank=True, verbose_name="change message"),
),
migrations.AlterField(
model_name="logentry",
name="changes",
field=models.JSONField(null=True, verbose_name="change message"),
),
]
class Migration(migrations.Migration):
dependencies = [
("auditlog", "0014_logentry_cid"),
]
operations = [*two_step_migrations()]

View file

@ -1,17 +0,0 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("auditlog", "0015_alter_logentry_changes"),
]
operations = [
migrations.AddField(
model_name="logentry",
name="remote_port",
field=models.PositiveIntegerField(
blank=True, null=True, verbose_name="remote port"
),
),
]

View file

@ -1,20 +0,0 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("auditlog", "0016_logentry_remote_port"),
]
operations = [
migrations.AddField(
model_name="logentry",
name="actor_email",
field=models.CharField(
null=True,
verbose_name="actor email",
blank=True,
max_length=254,
),
),
]

View file

@ -1,43 +1,27 @@
from urllib.parse import unquote
import json
from django import urls as urlresolvers
from django.conf import settings
from django.contrib import admin
from django.contrib.admin.views.main import PAGE_VAR
from django.core.exceptions import PermissionDenied
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.utils.html import format_html
from django.utils.text import capfirst
from django.utils.timezone import is_aware, localtime
from django.utils.translation import gettext_lazy as _
from django.utils.safestring import mark_safe
from django.utils.timezone import localtime
from auditlog import get_logentry_model
from auditlog.render import get_field_verbose_name, render_logentry_changes_html
from auditlog.signals import accessed
LogEntry = get_logentry_model()
from auditlog.models import LogEntry
MAX = 75
class LogEntryAdminMixin:
request: HttpRequest
CID_TITLE = _("Click to filter by records with this correlation id")
@admin.display(description=_("Created"))
class LogEntryAdminMixin(object):
def created(self, obj):
if is_aware(obj.timestamp):
return localtime(obj.timestamp)
return obj.timestamp
return localtime(obj.timestamp).strftime("%Y-%m-%d %H:%M:%S")
created.short_description = "Created"
@admin.display(description=_("User"))
def user_url(self, obj):
if obj.actor:
app_label, model = settings.AUTH_USER_MODEL.split(".")
viewname = f"admin:{app_label}_{model.lower()}_change"
viewname = "admin:%s_%s_change" % (app_label, model.lower())
try:
link = urlresolvers.reverse(viewname, args=[obj.actor.pk])
except NoReverseMatch:
@ -46,25 +30,25 @@ class LogEntryAdminMixin:
return "system"
@admin.display(description=_("Resource"))
user_url.short_description = "User"
def resource_url(self, obj):
app_label, model = obj.content_type.app_label, obj.content_type.model
viewname = f"admin:{app_label}_{model}_change"
viewname = "admin:%s_%s_change" % (app_label, model)
try:
args = [obj.object_pk] if obj.object_id is None else [obj.object_id]
link = urlresolvers.reverse(viewname, args=args)
except NoReverseMatch:
return obj.object_repr
else:
return format_html(
'<a href="{}">{} - {}</a>', link, obj.content_type, obj.object_repr
)
return format_html('<a href="{}">{}</a>', link, obj.object_repr)
resource_url.short_description = "Resource"
@admin.display(description=_("Changes"))
def msg_short(self, obj):
if obj.action in [LogEntry.Action.DELETE, LogEntry.Action.ACCESS]:
if obj.action == LogEntry.Action.DELETE:
return "" # delete
changes = obj.changes_dict
changes = json.loads(obj.changes)
s = "" if len(changes) == 1 else "s"
fields = ", ".join(changes.keys())
if len(fields) > MAX:
@ -72,108 +56,22 @@ class LogEntryAdminMixin:
fields = fields[:i] + " .."
return "%d change%s: %s" % (len(changes), s, fields)
@admin.display(description=_("Changes"))
msg_short.short_description = "Changes"
def msg(self, obj):
return render_logentry_changes_html(obj)
@admin.display(description="Correlation ID")
def cid_url(self, obj):
cid = obj.cid
if cid:
url = self._add_query_parameter("cid", cid)
return format_html(
'<a href="{}" title="{}">{}</a>', url, self.CID_TITLE, cid
if obj.action == LogEntry.Action.DELETE:
return "" # delete
changes = json.loads(obj.changes)
msg = "<table><tr><th>#</th><th>Field</th><th>From</th><th>To</th></tr>"
for i, field in enumerate(sorted(changes), 1):
value = [i, field] + (
["***", "***"] if field == "password" else changes[field]
)
msg += format_html(
"<tr><td>{}</td><td>{}</td><td>{}</td><td>{}</td></tr>", *value
)
def _add_query_parameter(self, key: str, value: str):
full_path = self.request.get_full_path()
delimiter = "&" if "?" in full_path else "?"
msg += "</table>"
return mark_safe(msg)
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:
def render_to_response(self, context, **response_kwargs):
obj = self.get_object()
accessed.send(obj.__class__, instance=obj)
return super().render_to_response(context, **response_kwargs)
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"))
msg.short_description = "Changes"

View file

@ -1,33 +1,18 @@
import ast
import contextlib
import json
from collections.abc import Callable
from copy import deepcopy
from datetime import timezone
from typing import Any
from dateutil import parser
from dateutil.tz import gettz
from django.conf import settings
from django.contrib.contenttypes.fields import GenericRelation
from django.contrib.contenttypes.models import ContentType
from django.core import serializers
from django.core.exceptions import (
FieldDoesNotExist,
ObjectDoesNotExist,
ValidationError,
)
from django.core.exceptions import FieldDoesNotExist
from django.db import DEFAULT_DB_ALIAS, models
from django.db.models import Q, QuerySet
from django.utils import formats
from django.utils import timezone as django_timezone
from django.utils import formats, timezone
from django.utils.encoding import smart_str
from django.utils.translation import gettext_lazy as _
from auditlog import get_logentry_model
from auditlog.diff import get_mask_function
DEFAULT_OBJECT_REPR = "<error forming object repr>"
from jsonfield.fields import JSONField
class LogEntryManager(models.Manager):
@ -35,37 +20,26 @@ class LogEntryManager(models.Manager):
Custom manager for the :py:class:`LogEntry` model.
"""
def log_create(self, instance, force_log: bool = False, **kwargs):
def log_create(self, instance, **kwargs):
"""
Helper method to create a new log entry. This method automatically populates some fields when no
explicit value is given.
Helper method to create a new log entry. This method automatically populates some fields when no explicit value
is given.
:param instance: The model instance to log a change for.
:type instance: Model
:param force_log: Create a LogEntry even if no changes exist.
:type force_log: bool
:param kwargs: Field overrides for the :py:class:`LogEntry` object.
:return: The new log entry or `None` if there were no changes.
:rtype: LogEntry
"""
from auditlog.cid import get_cid
changes = kwargs.get("changes", None)
pk = self._get_pk_value(instance)
if changes is not None or force_log:
if changes is not None:
kwargs.setdefault(
"content_type", ContentType.objects.get_for_model(instance)
)
kwargs.setdefault("object_pk", pk)
try:
object_repr = smart_str(instance)
except ObjectDoesNotExist:
object_repr = DEFAULT_OBJECT_REPR
kwargs.setdefault("object_repr", object_repr)
kwargs.setdefault(
"serialized_data", self._get_serialized_data_or_none(instance)
)
kwargs.setdefault("object_repr", smart_str(instance))
if isinstance(pk, int):
kwargs.setdefault("object_id", pk)
@ -74,62 +48,32 @@ class LogEntryManager(models.Manager):
if callable(get_additional_data):
kwargs.setdefault("additional_data", get_additional_data())
# set correlation id
kwargs.setdefault("cid", get_cid())
return self.create(**kwargs)
return None
def log_m2m_changes(
self, changed_queryset, instance, operation, field_name, **kwargs
):
"""Create a new "changed" log entry from m2m record.
:param changed_queryset: The added or removed related objects.
:type changed_queryset: QuerySet
:param instance: The model instance to log a change for.
:type instance: Model
:param operation: "add" or "delete".
:type action: str
:param field_name: The name of the changed m2m field.
:type field_name: str
:param kwargs: Field overrides for the :py:class:`LogEntry` object.
:return: The new log entry or `None` if there were no changes.
:rtype: LogEntry
"""
from auditlog.cid import get_cid
pk = self._get_pk_value(instance)
if changed_queryset:
kwargs.setdefault(
"content_type", ContentType.objects.get_for_model(instance)
# Delete log entries with the same pk as a newly created model. This should only be necessary when an pk is
# used twice.
if kwargs.get("action", None) is LogEntry.Action.CREATE:
if (
kwargs.get("object_id", None) is not None
and self.filter(
content_type=kwargs.get("content_type"),
object_id=kwargs.get("object_id"),
).exists()
):
self.filter(
content_type=kwargs.get("content_type"),
object_id=kwargs.get("object_id"),
).delete()
else:
self.filter(
content_type=kwargs.get("content_type"),
object_pk=kwargs.get("object_pk", ""),
).delete()
# save LogEntry to same database instance is using
db = instance._state.db
return (
self.create(**kwargs)
if db is None or db == ""
else self.using(db).create(**kwargs)
)
kwargs.setdefault("object_pk", pk)
try:
object_repr = smart_str(instance)
except ObjectDoesNotExist:
object_repr = DEFAULT_OBJECT_REPR
kwargs.setdefault("object_repr", object_repr)
kwargs.setdefault("action", LogEntry.Action.UPDATE)
if isinstance(pk, int):
kwargs.setdefault("object_id", pk)
get_additional_data = getattr(instance, "get_additional_data", None)
if callable(get_additional_data):
kwargs.setdefault("additional_data", get_additional_data())
objects = [smart_str(instance) for instance in changed_queryset]
kwargs["changes"] = {
field_name: {
"type": "m2m",
"operation": operation,
"objects": objects,
}
}
kwargs.setdefault("cid", get_cid())
return self.create(**kwargs)
return None
def get_for_object(self, instance):
@ -215,128 +159,44 @@ class LogEntryManager(models.Manager):
:type instance: Model
:return: The primary key value of the given model instance.
"""
# Should be equivalent to `instance.pk`.
pk_field = instance._meta.pk.attname
pk_field = instance._meta.pk.name
pk = getattr(instance, pk_field, None)
# Check to make sure that we got a pk not a model object.
# Should be guaranteed as we used `attname` above, not `name`.
assert not isinstance(pk, models.Model)
# Check to make sure that we got an pk not a model object.
if isinstance(pk, models.Model):
pk = self._get_pk_value(pk)
return pk
def _get_serialized_data_or_none(self, instance):
from auditlog.registry import auditlog
if not auditlog.contains(instance.__class__):
return None
opts = auditlog.get_serialize_options(instance.__class__)
if not opts["serialize_data"]:
return None
model_fields = auditlog.get_model_fields(instance.__class__)
kwargs = opts.get("serialize_kwargs", {})
if opts["serialize_auditlog_fields_only"]:
kwargs.setdefault(
"fields", self._get_applicable_model_fields(instance, model_fields)
)
instance_copy = self._get_copy_with_python_typed_fields(instance)
data = dict(
json.loads(serializers.serialize("json", (instance_copy,), **kwargs))[0]
)
mask_fields = model_fields["mask_fields"]
if mask_fields:
data = self._mask_serialized_fields(data, mask_fields, model_fields)
return data
def _get_copy_with_python_typed_fields(self, instance):
"""
Attempt to create copy of instance and coerce types on instance fields
The Django core serializer assumes that the values on object fields are
correctly typed to their respective fields. Updates made to an object's
in-memory state may not meet this assumption. To prevent this violation, values
are typed by calling `to_python` from the field object, the result is set on a
copy of the instance and the copy is sent to the serializer.
"""
try:
instance_copy = deepcopy(instance)
except TypeError:
instance_copy = instance
for field in instance_copy._meta.fields:
if not field.is_relation:
value = getattr(instance_copy, field.name)
try:
setattr(instance_copy, field.name, field.to_python(value))
except ValidationError:
continue
return instance_copy
def _get_applicable_model_fields(
self, instance, model_fields: dict[str, list[str]]
) -> list[str]:
include_fields = model_fields["include_fields"]
exclude_fields = model_fields["exclude_fields"]
all_field_names = [field.name for field in instance._meta.fields]
if not include_fields and not exclude_fields:
return all_field_names
return list(set(include_fields or all_field_names).difference(exclude_fields))
def _mask_serialized_fields(
self, data: dict[str, Any], mask_fields: list[str], model_fields: dict[str, Any]
) -> dict[str, Any]:
all_field_data = data.pop("fields")
mask_func = get_mask_function(model_fields.get("mask_callable"))
masked_field_data = {}
for key, value in all_field_data.items():
if isinstance(value, str) and key in mask_fields:
masked_field_data[key] = mask_func(value)
else:
masked_field_data[key] = value
data["fields"] = masked_field_data
return data
class AbstractLogEntry(models.Model):
class LogEntry(models.Model):
"""
Represents an entry in the audit log. The content type is saved along with the textual and numeric
(if available) primary key, as well as the textual representation of the object when it was saved.
It holds the action performed and the fields that were changed in the transaction.
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. It holds the action performed
and the fields that were changed in the transaction.
If AuditlogMiddleware is used, the actor will be set automatically. Keep in mind that
editing / re-saving LogEntry instances may set the actor to a wrong value - editing LogEntry
instances is not recommended (and it should not be necessary).
If AuditlogMiddleware is used, the actor will be set automatically. Keep in mind that editing / re-saving LogEntry
instances may set the actor to a wrong value - editing LogEntry instances is not recommended (and it should not be
necessary).
"""
class Action:
"""
The actions that Auditlog distinguishes: creating, updating and deleting objects. Viewing objects
is not logged. The values of the actions are numeric, a higher integer value means a more intrusive
action. This may be useful in some cases when comparing actions because the ``__lt``, ``__lte``,
``__gt``, ``__gte`` lookup filters can be used in queries.
The actions that Auditlog distinguishes: creating, updating and deleting objects. Viewing objects is not logged.
The values of the actions are numeric, a higher integer value means a more intrusive action. This may be useful
in some cases when comparing actions because the ``__lt``, ``__lte``, ``__gt``, ``__gte`` lookup filters can be
used in queries.
The valid actions are :py:attr:`Action.CREATE`, :py:attr:`Action.UPDATE`,
:py:attr:`Action.DELETE` and :py:attr:`Action.ACCESS`.
The valid actions are :py:attr:`Action.CREATE`, :py:attr:`Action.UPDATE` and :py:attr:`Action.DELETE`.
"""
CREATE = 0
UPDATE = 1
DELETE = 2
ACCESS = 3
choices = (
(CREATE, _("create")),
(UPDATE, _("update")),
(DELETE, _("delete")),
(ACCESS, _("access")),
)
content_type = models.ForeignKey(
@ -352,12 +212,10 @@ class AbstractLogEntry(models.Model):
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=Action.choices, verbose_name=_("action"), db_index=True
choices=Action.choices, verbose_name=_("action")
)
changes_text = models.TextField(blank=True, verbose_name=_("change message"))
changes = models.JSONField(null=True, verbose_name=_("change message"))
changes = models.TextField(blank=True, verbose_name=_("change message"))
actor = models.ForeignKey(
to=settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
@ -366,39 +224,22 @@ class AbstractLogEntry(models.Model):
related_name="+",
verbose_name=_("actor"),
)
cid = models.CharField(
max_length=255,
db_index=True,
blank=True,
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(
default=django_timezone.now,
db_index=True,
verbose_name=_("timestamp"),
)
additional_data = models.JSONField(
timestamp = models.DateTimeField(auto_now_add=True, verbose_name=_("timestamp"))
additional_data = JSONField(
blank=True, null=True, verbose_name=_("additional data")
)
actor_email = models.CharField(
blank=True, null=True, max_length=254, verbose_name=_("actor email")
)
objects = LogEntryManager()
class Meta:
abstract = True
get_latest_by = "timestamp"
ordering = ["-timestamp"]
verbose_name = _("log entry")
verbose_name_plural = _("log entries")
index_together = ("timestamp", "id")
def __str__(self):
if self.action == self.Action.CREATE:
@ -417,39 +258,34 @@ class AbstractLogEntry(models.Model):
"""
:return: The changes recorded in this log entry as a dictionary object.
"""
return changes_func(self)
try:
return json.loads(self.changes)
except ValueError:
return {}
@property
def changes_str(self, colon=": ", arrow=" \u2192 ", separator="; "):
"""
Return the changes recorded in this log entry as a string. The formatting of the string can be
customized by setting alternate values for colon, arrow and separator. If the formatting is still
not satisfying, please use :py:func:`LogEntry.changes_dict` and format the string yourself.
Return the changes recorded in this log entry as a string. The formatting of the string can be customized by
setting alternate values for colon, arrow and separator. If the formatting is still not satisfying, please use
:py:func:`LogEntry.changes_dict` and format the string yourself.
:param colon: The string to place between the field name and the values.
:param arrow: The string to place between each old and new value (non-m2m field changes only).
:param arrow: The string to place between each old and new value.
:param separator: The string to place between each field.
:return: A readable string of the changes in this log entry.
"""
substrings = []
for field, value in sorted(self.changes_dict.items()):
if isinstance(value, (list, tuple)) and len(value) == 2:
# handle regular field change
substring = "{field_name:s}{colon:s}{old:s}{arrow:s}{new:s}".format(
field_name=field,
colon=colon,
old=value[0],
arrow=arrow,
new=value[1],
)
substrings.append(substring)
elif isinstance(value, dict) and value.get("type") == "m2m":
# handle m2m change
substring = (
f"{field}{colon}{value['operation']} {sorted(value['objects'])}"
)
substrings.append(substring)
for field, values in self.changes_dict.items():
substring = "{field_name:s}{colon:s}{old:s}{arrow:s}{new:s}".format(
field_name=field,
colon=colon,
old=values[0],
arrow=arrow,
new=values[1],
)
substrings.append(substring)
return separator.join(substrings)
@ -458,25 +294,14 @@ class AbstractLogEntry(models.Model):
"""
:return: The changes recorded in this log entry intended for display to users as a dictionary object.
"""
# Get the model and model_fields
from auditlog.registry import auditlog
# Get the model and model_fields, but gracefully handle the case where the model no longer exists
model = self.content_type.model_class()
model_fields = None
if auditlog.contains(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
model_fields = auditlog.get_model_fields(model._meta.model)
changes_display_dict = {}
# grab the changes_dict and iterate through
for field_name, values in changes_dict.items():
for field_name, values in self.changes_dict.items():
# try to get the field attribute on the model
try:
field = model._meta.get_field(field_name)
@ -484,7 +309,7 @@ class AbstractLogEntry(models.Model):
changes_display_dict[field_name] = values
continue
values_display = []
# handle choices fields and Postgres ArrayField to get human-readable version
# handle choices fields and Postgres ArrayField to get human readable version
choices_dict = None
if getattr(field, "choices", []):
choices_dict = dict(field.choices)
@ -526,78 +351,40 @@ class AbstractLogEntry(models.Model):
value = formats.localize(value)
except ValueError:
pass
elif field_type in ["ForeignKey", "OneToOneField"]:
value = self._get_changes_display_for_fk_field(field, value)
truncate_at = settings.AUDITLOG_CHANGE_DISPLAY_TRUNCATE_LENGTH
if 0 <= truncate_at < len(value):
value = value[:truncate_at] + ("..." if truncate_at > 0 else "")
# check if length is longer than 140 and truncate with ellipsis
if len(value) > 140:
value = "{}...".format(value[:140])
values_display.append(value)
# Use verbose_name from mapping if available, otherwise determine from field
if model_fields and field.name in model_fields["mapping_fields"]:
verbose_name = model_fields["mapping_fields"][field.name]
else:
verbose_name = getattr(field, "verbose_name", field.name)
verbose_name = model_fields["mapping_fields"].get(
field.name, getattr(field, "verbose_name", field.name)
)
changes_display_dict[verbose_name] = values_display
return changes_display_dict
def _get_changes_display_for_fk_field(
self, field: models.ForeignKey | models.OneToOneField, value: Any
) -> str:
"""
:return: A string representing a given FK value and the field to which it belongs
"""
# Return "None" if the FK value is "None".
if value == "None":
return value
# Attempt to convert given value to the PK type for the related model
try:
pk_value = field.related_model._meta.pk.to_python(value)
# ValidationError will handle legacy values where string representations were
# stored rather than PKs. This will also handle cases where the PK type is
# changed between the time the LogEntry is created and this method is called.
except ValidationError:
return value
# Attempt to return the string representation of the object
try:
related_model_manager = _get_manager_from_settings(field.related_model)
return smart_str(related_model_manager.get(pk=pk_value))
# ObjectDoesNotExist will be raised if the object was deleted.
except ObjectDoesNotExist:
return f"Deleted '{field.related_model.__name__}' ({value})"
class LogEntry(AbstractLogEntry):
class Meta(AbstractLogEntry.Meta):
swappable = "AUDITLOG_LOGENTRY_MODEL"
class AuditlogHistoryField(GenericRelation):
"""
A subclass of py:class:`django.contrib.contenttypes.fields.GenericRelation` that sets some default
variables. This makes it easier to access Auditlog's log entries, for example in templates.
A subclass of py:class:`django.contrib.contenttypes.fields.GenericRelation` that sets some default variables. This
makes it easier to access Auditlog's log entries, for example in templates.
By default, this field will assume that your primary keys are numeric, simply because this is the most
common case. However, if you have a non-integer primary key, you can simply pass ``pk_indexable=False``
to the constructor, and Auditlog will fall back to using a non-indexed text based field for this model.
By default this field will assume that your primary keys are numeric, simply because this is the most common case.
However, if you have a non-integer primary key, you can simply pass ``pk_indexable=False`` to the constructor, and
Auditlog will fall back to using a non-indexed text based field for this model.
Using this field will not automatically register the model for automatic logging. This is done so you
can be more flexible with how you use this field.
Using this field will not automatically register the model for automatic logging. This is done so you can be more
flexible with how you use this field.
:param pk_indexable: Whether the primary key for this model is not an :py:class:`int` or :py:class:`long`.
:type pk_indexable: bool
:param delete_related: Delete referenced auditlog entries together with the tracked object.
Defaults to False to keep the integrity of the auditlog.
:param delete_related: By default, including a generic relation into a model will cause all related objects to be
cascade-deleted when the parent object is deleted. Passing False to this overrides this behavior, retaining
the full auditlog history for the object. Defaults to True, because that's Django's default behavior.
:type delete_related: bool
"""
def __init__(self, pk_indexable=True, delete_related=False, **kwargs):
kwargs["to"] = get_logentry_model()
def __init__(self, pk_indexable=True, delete_related=True, **kwargs):
kwargs["to"] = LogEntry
if pk_indexable:
kwargs["object_id_field"] = "object_id"
@ -606,14 +393,14 @@ class AuditlogHistoryField(GenericRelation):
kwargs["content_type_field"] = "content_type"
self.delete_related = delete_related
super().__init__(**kwargs)
super(AuditlogHistoryField, self).__init__(**kwargs)
def bulk_related_objects(self, objs, using=DEFAULT_DB_ALIAS):
"""
Return all objects related to ``objs`` via this ``GenericRelation``.
"""
if self.delete_related:
return super().bulk_related_objects(objs, using)
return super(AuditlogHistoryField, self).bulk_related_objects(objs, using)
# When deleting, Collector.collect() finds related objects using this
# method. However, because we don't want to delete these related
@ -621,35 +408,13 @@ class AuditlogHistoryField(GenericRelation):
return []
# should I add a signal receiver for setting_changed?
changes_func = None
# South compatibility for AuditlogHistoryField
try:
from south.modelsinspector import add_introspection_rules
def _changes_func() -> Callable[[LogEntry], dict]:
def json_then_text(instance: LogEntry) -> dict:
if instance.changes:
return instance.changes
elif instance.changes_text:
with contextlib.suppress(ValueError):
return json.loads(instance.changes_text)
return {}
def default(instance: LogEntry) -> dict:
return instance.changes or {}
if settings.AUDITLOG_USE_TEXT_CHANGES_IF_JSON_IS_NOT_PRESENT:
return json_then_text
return default
def _get_manager_from_settings(model: type[models.Model]) -> models.Manager:
"""
Get model manager as selected by AUDITLOG_USE_BASE_MANAGER.
- True: return model._meta.base_manager
- False: return model._meta.default_manager
"""
if settings.AUDITLOG_USE_BASE_MANAGER:
return model._meta.base_manager
else:
return model._meta.default_manager
add_introspection_rules([], ["^auditlog\.models\.AuditlogHistoryField"])
raise DeprecationWarning(
"South support will be dropped in django-auditlog 0.4.0 or later."
)
except ImportError:
pass

View file

@ -1,36 +1,9 @@
from functools import wraps
import json
from django.conf import settings
from auditlog import get_logentry_model
from auditlog.context import auditlog_disabled
from auditlog.diff import model_instance_diff
from auditlog.models import _get_manager_from_settings
from auditlog.signals import post_log, pre_log
from auditlog.models import LogEntry
def check_disable(signal_handler):
"""
Decorator that passes along disabled in kwargs if any of the following is true:
- 'auditlog_disabled' from threadlocal is true
- raw = True and AUDITLOG_DISABLE_ON_RAW_SAVE is True
"""
@wraps(signal_handler)
def wrapper(*args, **kwargs):
try:
auditlog_disabled_value = auditlog_disabled.get()
except LookupError:
auditlog_disabled_value = False
if not auditlog_disabled_value and not (
kwargs.get("raw") and settings.AUDITLOG_DISABLE_ON_RAW_SAVE
):
signal_handler(*args, **kwargs)
return wrapper
@check_disable
def log_create(sender, instance, created, **kwargs):
"""
Signal receiver that creates a log entry when a model instance is first saved to the database.
@ -38,38 +11,40 @@ def log_create(sender, instance, created, **kwargs):
Direct use is discouraged, connect your model through :py:func:`auditlog.registry.register` instead.
"""
if created:
_create_log_entry(
action=get_logentry_model().Action.CREATE,
instance=instance,
sender=sender,
diff_old=None,
diff_new=instance,
use_json_for_changes=settings.AUDITLOG_STORE_JSON_CHANGES,
changes = model_instance_diff(None, instance)
log_entry = LogEntry.objects.log_create(
instance,
action=LogEntry.Action.CREATE,
changes=json.dumps(changes),
)
@check_disable
def log_update(sender, instance, **kwargs):
"""
Signal receiver that creates a log entry when a model instance is changed and saved to the database.
Direct use is discouraged, connect your model through :py:func:`auditlog.registry.register` instead.
"""
if not instance._state.adding and instance.pk is not None:
update_fields = kwargs.get("update_fields", None)
old = _get_manager_from_settings(sender).filter(pk=instance.pk).first()
_create_log_entry(
action=get_logentry_model().Action.UPDATE,
instance=instance,
sender=sender,
diff_old=old,
diff_new=instance,
fields_to_check=update_fields,
use_json_for_changes=settings.AUDITLOG_STORE_JSON_CHANGES,
)
if instance.pk is not None:
try:
old = sender.objects.get(pk=instance.pk)
except sender.DoesNotExist:
pass
else:
new = instance
changes = model_instance_diff(old, new)
# Log an entry only if there are changes
if changes:
log_entry = LogEntry.objects.log_create(
instance,
action=LogEntry.Action.UPDATE,
changes=json.dumps(changes),
)
@check_disable
def log_delete(sender, instance, **kwargs):
"""
Signal receiver that creates a log entry when a model instance is deleted from the database.
@ -77,122 +52,10 @@ def log_delete(sender, instance, **kwargs):
Direct use is discouraged, connect your model through :py:func:`auditlog.registry.register` instead.
"""
if instance.pk is not None:
_create_log_entry(
action=get_logentry_model().Action.DELETE,
instance=instance,
sender=sender,
diff_old=instance,
diff_new=None,
use_json_for_changes=settings.AUDITLOG_STORE_JSON_CHANGES,
changes = model_instance_diff(instance, None)
log_entry = LogEntry.objects.log_create(
instance,
action=LogEntry.Action.DELETE,
changes=json.dumps(changes),
)
def log_access(sender, instance, **kwargs):
"""
Signal receiver that creates a log entry when a model instance is accessed in a AccessLogDetailView.
Direct use is discouraged, connect your model through :py:func:`auditlog.registry.register` instead.
"""
if instance.pk is not None:
_create_log_entry(
action=get_logentry_model().Action.ACCESS,
instance=instance,
sender=sender,
diff_old=None,
diff_new=None,
force_log=True,
use_json_for_changes=settings.AUDITLOG_STORE_JSON_CHANGES,
)
def _create_log_entry(
action,
instance,
sender,
diff_old,
diff_new,
fields_to_check=None,
force_log=False,
use_json_for_changes=False,
):
pre_log_results = pre_log.send(
sender,
instance=instance,
action=action,
)
if any(item[1] is False for item in pre_log_results):
return
LogEntry = get_logentry_model()
error = None
log_entry = None
changes = None
try:
changes = model_instance_diff(
diff_old,
diff_new,
fields_to_check=fields_to_check,
use_json_for_changes=use_json_for_changes,
)
if force_log or changes:
log_entry = LogEntry.objects.log_create(
instance,
action=action,
changes=changes,
force_log=force_log,
)
except BaseException as e:
error = e
finally:
if log_entry or error:
post_log.send(
sender,
instance=instance,
instance_old=diff_old,
action=action,
error=error,
pre_log_results=pre_log_results,
changes=changes,
log_entry=log_entry,
log_created=log_entry is not None,
use_json_for_changes=settings.AUDITLOG_STORE_JSON_CHANGES,
)
if error:
raise error
def make_log_m2m_changes(field_name):
"""Return a handler for m2m_changed with field_name enclosed."""
@check_disable
def log_m2m_changes(signal, action, **kwargs):
"""Handle m2m_changed and call LogEntry.objects.log_m2m_changes as needed."""
if action not in ["post_add", "post_clear", "post_remove"]:
return
LogEntry = get_logentry_model()
model_manager = _get_manager_from_settings(kwargs["model"])
if action == "post_clear":
changed_queryset = model_manager.all()
else:
changed_queryset = model_manager.filter(pk__in=kwargs["pk_set"])
if action in ["post_add"]:
LogEntry.objects.log_m2m_changes(
changed_queryset,
kwargs["instance"],
"add",
field_name,
)
elif action in ["post_remove", "post_clear"]:
LogEntry.objects.log_m2m_changes(
changed_queryset,
kwargs["instance"],
"delete",
field_name,
)
return log_m2m_changes

View file

@ -1,50 +1,28 @@
import copy
from collections import defaultdict
from collections.abc import Callable, Collection, Iterable
from typing import Any
from typing import Callable, Dict, List, Optional, Tuple
from django.apps import apps
from django.db.models import ManyToManyField, Model
from django.db.models import Model
from django.db.models.base import ModelBase
from django.db.models.signals import (
ModelSignal,
m2m_changed,
post_delete,
post_save,
pre_save,
)
from django.db.models.signals import ModelSignal, post_delete, post_save, pre_save
from auditlog.conf import settings
from auditlog.signals import accessed
DispatchUID = tuple[int, int, int]
DispatchUID = Tuple[int, str, int]
class AuditLogRegistrationError(Exception):
pass
class AuditlogModelRegistry:
class AuditlogModelRegistry(object):
"""
A registry that keeps track of the models that use Auditlog to track changes.
"""
DEFAULT_EXCLUDE_MODELS = (settings.AUDITLOG_LOGENTRY_MODEL, "admin.LogEntry")
def __init__(
self,
create: bool = True,
update: bool = True,
delete: bool = True,
access: bool = True,
m2m: bool = True,
custom: dict[ModelSignal, Callable] | None = None,
custom: Optional[Dict[ModelSignal, Callable]] = None,
):
from auditlog.receivers import log_access, log_create, log_delete, log_update
from auditlog.receivers import log_create, log_delete, log_update
self._registry = {}
self._signals = {}
self._m2m_signals = defaultdict(dict)
if create:
self._signals[post_save] = log_create
@ -52,9 +30,6 @@ class AuditlogModelRegistry:
self._signals[pre_save] = log_update
if delete:
self._signals[post_delete] = log_delete
if access:
self._signals[accessed] = log_access
self._m2m = m2m
if custom is not None:
self._signals.update(custom)
@ -62,15 +37,9 @@ class AuditlogModelRegistry:
def register(
self,
model: ModelBase = None,
include_fields: list[str] | None = None,
exclude_fields: list[str] | None = None,
mapping_fields: dict[str, str] | None = None,
mask_fields: list[str] | None = None,
mask_callable: str | None = None,
m2m_fields: Collection[str] | None = None,
serialize_data: bool = False,
serialize_kwargs: dict[str, Any] | None = None,
serialize_auditlog_fields_only: bool = False,
include_fields: Optional[List[str]] = None,
exclude_fields: Optional[List[str]] = None,
mapping_fields: Optional[Dict[str, str]] = None,
):
"""
Register a model with auditlog. Auditlog will then track mutations on this model's instances.
@ -79,13 +48,7 @@ class AuditlogModelRegistry:
:param include_fields: The fields to include. Implicitly excludes all other fields.
:param exclude_fields: The fields to exclude. Overrides the fields to include.
:param mapping_fields: Mapping from field names to strings in diff.
:param mask_fields: The fields to mask for sensitive info.
:param mask_callable: The dotted path to a callable that will be used for masking. If not provided,
the default mask_callable will be used.
:param m2m_fields: The fields to handle as many to many.
:param serialize_data: Option to include a dictionary of the objects state in the auditlog.
:param serialize_kwargs: Optional kwargs to pass to Django serializer
:param serialize_auditlog_fields_only: Only fields being considered in changes will be serialized.
"""
if include_fields is None:
@ -94,24 +57,6 @@ class AuditlogModelRegistry:
exclude_fields = []
if mapping_fields is None:
mapping_fields = {}
if mask_fields is None:
mask_fields = []
if m2m_fields is None:
m2m_fields = set()
if serialize_kwargs is None:
serialize_kwargs = {}
if (serialize_kwargs or serialize_auditlog_fields_only) and not serialize_data:
raise AuditLogRegistrationError(
"Serializer options were given but the 'serialize_data' option is not "
"set. Did you forget to set serialized_data to True?"
)
for fld in settings.AUDITLOG_EXCLUDE_TRACKING_FIELDS:
exclude_fields.append(fld)
for fld in settings.AUDITLOG_MASK_TRACKING_FIELDS:
mask_fields.append(fld)
def registrar(cls):
"""Register models for a given class."""
@ -122,12 +67,6 @@ class AuditlogModelRegistry:
"include_fields": include_fields,
"exclude_fields": exclude_fields,
"mapping_fields": mapping_fields,
"mask_fields": mask_fields,
"mask_callable": mask_callable,
"m2m_fields": m2m_fields,
"serialize_data": serialize_data,
"serialize_kwargs": serialize_kwargs,
"serialize_auditlog_fields_only": serialize_auditlog_fields_only,
}
self._connect_signals(cls)
@ -167,7 +106,8 @@ class AuditlogModelRegistry:
else:
self._disconnect_signals(model)
def get_models(self) -> list[ModelBase]:
def get_models(self) -> List[ModelBase]:
"""Get a list of all registered models."""
return list(self._registry.keys())
def get_model_fields(self, model: ModelBase):
@ -175,43 +115,17 @@ class AuditlogModelRegistry:
"include_fields": list(self._registry[model]["include_fields"]),
"exclude_fields": list(self._registry[model]["exclude_fields"]),
"mapping_fields": dict(self._registry[model]["mapping_fields"]),
"mask_fields": list(self._registry[model]["mask_fields"]),
"mask_callable": self._registry[model]["mask_callable"],
}
def get_serialize_options(self, model: ModelBase):
return {
"serialize_data": bool(self._registry[model]["serialize_data"]),
"serialize_kwargs": dict(self._registry[model]["serialize_kwargs"]),
"serialize_auditlog_fields_only": bool(
self._registry[model]["serialize_auditlog_fields_only"]
),
}
def _connect_signals(self, model):
"""
Connect signals for the model.
"""
from auditlog.receivers import make_log_m2m_changes
for signal, receiver in self._signals.items():
for signal in self._signals:
receiver = self._signals[signal]
signal.connect(
receiver,
sender=model,
dispatch_uid=self._dispatch_uid(signal, receiver),
receiver, sender=model, dispatch_uid=self._dispatch_uid(signal, model)
)
if self._m2m:
for field_name in self._registry[model]["m2m_fields"]:
receiver = make_log_m2m_changes(field_name)
self._m2m_signals[model][field_name] = receiver
field = getattr(model, field_name)
m2m_model = getattr(field, "through")
m2m_changed.connect(
receiver,
sender=m2m_model,
dispatch_uid=self._m2m_dispatch_uid(m2m_changed, m2m_model),
)
def _disconnect_signals(self, model):
"""
@ -219,167 +133,14 @@ class AuditlogModelRegistry:
"""
for signal, receiver in self._signals.items():
signal.disconnect(
sender=model, dispatch_uid=self._dispatch_uid(signal, receiver)
sender=model, dispatch_uid=self._dispatch_uid(signal, model)
)
for field_name, receiver in self._m2m_signals[model].items():
field = getattr(model, field_name)
m2m_model = getattr(field, "through")
m2m_changed.disconnect(
sender=m2m_model,
dispatch_uid=self._m2m_dispatch_uid(m2m_changed, m2m_model),
)
del self._m2m_signals[model]
def _dispatch_uid(self, signal, receiver) -> DispatchUID:
"""Generate a dispatch_uid which is unique for a combination of self, signal, and receiver."""
return id(self), id(signal), id(receiver)
def _m2m_dispatch_uid(self, signal, sender) -> DispatchUID:
"""Generate a dispatch_uid which is unique for a combination of self, signal, and sender."""
return id(self), id(signal), id(sender)
def _get_model_classes(self, app_model: str) -> list[ModelBase]:
try:
try:
app_label, model_name = app_model.split(".")
return [apps.get_model(app_label, model_name)]
except ValueError:
return apps.get_app_config(app_model).get_models()
except LookupError:
return []
def _get_exclude_models(
self, exclude_tracking_models: Iterable[str]
) -> list[ModelBase]:
exclude_models = [
model
for app_model in tuple(exclude_tracking_models)
+ self.DEFAULT_EXCLUDE_MODELS
for model in self._get_model_classes(app_model)
]
return exclude_models
def _register_models(self, models: Iterable[str | dict[str, Any]]) -> None:
models = copy.deepcopy(models)
for model in models:
if isinstance(model, str):
for model_class in self._get_model_classes(model):
self.unregister(model_class)
self.register(model_class)
elif isinstance(model, dict):
appmodel = self._get_model_classes(model["model"])
if not appmodel:
raise AuditLogRegistrationError(
f"An error was encountered while registering model '{model['model']}' - "
"make sure the app is registered correctly."
)
model["model"] = appmodel[0]
self.unregister(model["model"])
self.register(**model)
def register_from_settings(self):
def _dispatch_uid(self, signal, model) -> DispatchUID:
"""
Register models from settings variables
Generate a dispatch_uid.
"""
if not isinstance(settings.AUDITLOG_INCLUDE_ALL_MODELS, bool):
raise TypeError("Setting 'AUDITLOG_INCLUDE_ALL_MODELS' must be a boolean")
if not isinstance(settings.AUDITLOG_DISABLE_ON_RAW_SAVE, bool):
raise TypeError("Setting 'AUDITLOG_DISABLE_ON_RAW_SAVE' must be a boolean")
if not isinstance(settings.AUDITLOG_EXCLUDE_TRACKING_MODELS, (list, tuple)):
raise TypeError(
"Setting 'AUDITLOG_EXCLUDE_TRACKING_MODELS' must be a list or tuple"
)
if (
not settings.AUDITLOG_INCLUDE_ALL_MODELS
and settings.AUDITLOG_EXCLUDE_TRACKING_MODELS
):
raise ValueError(
"In order to use setting 'AUDITLOG_EXCLUDE_TRACKING_MODELS', "
"setting 'AUDITLOG_INCLUDE_ALL_MODELS' must set to 'True'"
)
if (
settings.AUDITLOG_EXCLUDE_TRACKING_FIELDS
and not settings.AUDITLOG_INCLUDE_ALL_MODELS
):
raise ValueError(
"In order to use 'AUDITLOG_EXCLUDE_TRACKING_FIELDS', "
"setting 'AUDITLOG_INCLUDE_ALL_MODELS' must be set to 'True'"
)
if (
settings.AUDITLOG_MASK_TRACKING_FIELDS
and not settings.AUDITLOG_INCLUDE_ALL_MODELS
):
raise ValueError(
"In order to use 'AUDITLOG_MASK_TRACKING_FIELDS', "
"setting 'AUDITLOG_INCLUDE_ALL_MODELS' must be set to 'True'"
)
if not isinstance(settings.AUDITLOG_INCLUDE_TRACKING_MODELS, (list, tuple)):
raise TypeError(
"Setting 'AUDITLOG_INCLUDE_TRACKING_MODELS' must be a list or tuple"
)
if not isinstance(settings.AUDITLOG_EXCLUDE_TRACKING_FIELDS, (list, tuple)):
raise TypeError(
"Setting 'AUDITLOG_EXCLUDE_TRACKING_FIELDS' must be a list or tuple"
)
if not isinstance(settings.AUDITLOG_MASK_TRACKING_FIELDS, (list, tuple)):
raise TypeError(
"Setting 'AUDITLOG_MASK_TRACKING_FIELDS' must be a list or tuple"
)
for item in settings.AUDITLOG_INCLUDE_TRACKING_MODELS:
if not isinstance(item, (str, dict)):
raise TypeError(
"Setting 'AUDITLOG_INCLUDE_TRACKING_MODELS' items must be str or dict"
)
if isinstance(item, dict):
if "model" not in item:
raise ValueError(
"Setting 'AUDITLOG_INCLUDE_TRACKING_MODELS' dict items must contain 'model' key"
)
if "." not in item["model"]:
raise ValueError(
"Setting 'AUDITLOG_INCLUDE_TRACKING_MODELS' model must be in the "
"format <app_name>.<model_name>"
)
if settings.AUDITLOG_INCLUDE_ALL_MODELS:
exclude_models = self._get_exclude_models(
settings.AUDITLOG_EXCLUDE_TRACKING_MODELS
)
for model in apps.get_models(include_auto_created=True):
if model in exclude_models:
continue
meta = model._meta
if not meta.managed:
continue
m2m_fields = [
m.name for m in meta.get_fields() if isinstance(m, ManyToManyField)
]
exclude_fields = [
i.related_name
for i in meta.related_objects
if i.related_name and not i.related_model._meta.managed
]
self.register(
model=model, m2m_fields=m2m_fields, exclude_fields=exclude_fields
)
if not isinstance(settings.AUDITLOG_STORE_JSON_CHANGES, bool):
raise TypeError("Setting 'AUDITLOG_STORE_JSON_CHANGES' must be a boolean")
self._register_models(settings.AUDITLOG_INCLUDE_TRACKING_MODELS)
return self.__hash__(), model.__qualname__, signal.__hash__()
auditlog = AuditlogModelRegistry()

View file

@ -1,95 +0,0 @@
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

@ -1,70 +0,0 @@
import django.dispatch
accessed = django.dispatch.Signal()
pre_log = django.dispatch.Signal()
"""
Whenever an audit log entry is written, this signal
is sent before writing the log.
Keyword arguments sent with this signal:
:param class sender:
The model class that's being audited.
:param Any instance:
The actual instance that's being audited.
:param Action action:
The action on the model resulting in an
audit log entry. Type: :class:`auditlog.models.LogEntry.Action`
The receivers' return values are sent to any :func:`post_log`
signal receivers, with one exception: if any receiver returns False,
no logging will be made. This can be useful if logging should be
conditionally enabled / disabled
"""
post_log = django.dispatch.Signal()
"""
Whenever an audit log entry is written, this signal
is sent after writing the log.
This signal is also fired when there is an error in creating the log.
Keyword arguments sent with this signal:
:param class sender:
The model class that's being audited.
:param Any instance:
The actual instance that's being audited.
:param Action action:
The action on the model resulting in an
audit log entry. Type: :class:`auditlog.models.LogEntry.Action`
:param Optional[dict] changes:
The changes that were logged. If there was en error while determining the changes,
this will be None. In some cases, such as when logging access to the instance,
the changes will be an empty dict.
:param Optional[LogEntry] log_entry:
The log entry that was created and stored in the database. If there was an error,
this will be None.
:param bool log_created:
Was the log actually created?
This could be false if there was an error in creating the log.
:param Optional[Exception] error:
The error, if one occurred while saving the audit log entry. ``None``,
otherwise
:param List[Tuple[method,Any]] pre_log_results:
List of tuple pairs ``[(pre_log_receiver, pre_log_response)]``, where
``pre_log_receiver`` is the receiver method, and ``pre_log_response`` is the
corresponding response of that method. If there are no :const:`pre_log` receivers,
then the list will be empty. ``pre_log_receiver`` is guaranteed to be
non-null, but ``pre_log_response`` may be ``None``. This depends on the corresponding
``pre_log_receiver``'s return value.
"""

View file

@ -1,18 +0,0 @@
{% 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

@ -1,160 +0,0 @@
{% 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

@ -1,16 +0,0 @@
{% 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

@ -1,16 +0,0 @@
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)

View file

@ -0,0 +1 @@
default_app_config = "auditlog_tests.apps.AuditlogTestConfig"

View file

@ -2,4 +2,4 @@ from django.apps import AppConfig
class AuditlogTestConfig(AppConfig):
name = "test_app"
name = "auditlog_tests"

View file

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

View file

@ -1,138 +0,0 @@
# 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

@ -1,7 +0,0 @@
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

@ -1,47 +0,0 @@
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

@ -1,6 +0,0 @@
#!/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

@ -3,7 +3,7 @@ import os
import sys
if __name__ == "__main__":
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_settings")
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "auditlog_tests.test_settings")
from django.core.management import execute_from_command_line

View file

@ -1,12 +0,0 @@
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

234
auditlog_tests/models.py Normal file
View file

@ -0,0 +1,234 @@
import uuid
from django.contrib.postgres.fields import ArrayField
from django.db import models
from auditlog.models import AuditlogHistoryField
from auditlog.registry import auditlog
@auditlog.register()
class SimpleModel(models.Model):
"""
A simple model with no special things going on.
"""
text = models.TextField(blank=True)
boolean = models.BooleanField(default=False)
integer = models.IntegerField(blank=True, null=True)
datetime = models.DateTimeField(auto_now=True)
history = AuditlogHistoryField()
class AltPrimaryKeyModel(models.Model):
"""
A model with a non-standard primary key.
"""
key = models.CharField(max_length=100, primary_key=True)
text = models.TextField(blank=True)
boolean = models.BooleanField(default=False)
integer = models.IntegerField(blank=True, null=True)
datetime = models.DateTimeField(auto_now=True)
history = AuditlogHistoryField(pk_indexable=False)
class UUIDPrimaryKeyModel(models.Model):
"""
A model with a UUID primary key.
"""
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
text = models.TextField(blank=True)
boolean = models.BooleanField(default=False)
integer = models.IntegerField(blank=True, null=True)
datetime = models.DateTimeField(auto_now=True)
history = AuditlogHistoryField(pk_indexable=False)
class ProxyModel(SimpleModel):
"""
A model that is a proxy for another model.
"""
class Meta:
proxy = True
class RelatedModel(models.Model):
"""
A model with a foreign key.
"""
related = models.ForeignKey(to="self", on_delete=models.CASCADE)
history = AuditlogHistoryField()
class ManyRelatedModel(models.Model):
"""
A model with a many to many relation.
"""
related = models.ManyToManyField("self")
history = AuditlogHistoryField()
@auditlog.register(include_fields=["label"])
class SimpleIncludeModel(models.Model):
"""
A simple model used for register's include_fields kwarg
"""
label = models.CharField(max_length=100)
text = models.TextField(blank=True)
history = AuditlogHistoryField()
class SimpleExcludeModel(models.Model):
"""
A simple model used for register's exclude_fields kwarg
"""
label = models.CharField(max_length=100)
text = models.TextField(blank=True)
history = AuditlogHistoryField()
class SimpleMappingModel(models.Model):
"""
A simple model used for register's mapping_fields kwarg
"""
sku = models.CharField(max_length=100)
vtxt = models.CharField(verbose_name="Version", max_length=100)
not_mapped = models.CharField(max_length=100)
history = AuditlogHistoryField()
class AdditionalDataIncludedModel(models.Model):
"""
A model where get_additional_data is defined which allows for logging extra
information about the model in JSON
"""
label = models.CharField(max_length=100)
text = models.TextField(blank=True)
related = models.ForeignKey(to=SimpleModel, on_delete=models.CASCADE)
history = AuditlogHistoryField()
def get_additional_data(self):
"""
Returns JSON that captures a snapshot of additional details of the
model instance. This method, if defined, is accessed by auditlog
manager and added to each logentry instance on creation.
"""
object_details = {
"related_model_id": self.related.id,
"related_model_text": self.related.text,
}
return object_details
class DateTimeFieldModel(models.Model):
"""
A model with a DateTimeField, used to test DateTimeField
changes are detected properly.
"""
label = models.CharField(max_length=100)
timestamp = models.DateTimeField()
date = models.DateField()
time = models.TimeField()
naive_dt = models.DateTimeField(null=True, blank=True)
history = AuditlogHistoryField()
class ChoicesFieldModel(models.Model):
"""
A model with a CharField restricted to a set of choices.
This model is used to test the changes_display_dict method.
"""
RED = "r"
YELLOW = "y"
GREEN = "g"
STATUS_CHOICES = (
(RED, "Red"),
(YELLOW, "Yellow"),
(GREEN, "Green"),
)
status = models.CharField(max_length=1, choices=STATUS_CHOICES)
multiplechoice = models.CharField(max_length=255, choices=STATUS_CHOICES)
history = AuditlogHistoryField()
class CharfieldTextfieldModel(models.Model):
"""
A model with a max length CharField and a Textfield.
This model is used to test the changes_display_dict
method's ability to truncate long text.
"""
longchar = models.CharField(max_length=255)
longtextfield = models.TextField()
history = AuditlogHistoryField()
class PostgresArrayFieldModel(models.Model):
"""
Test auditlog with Postgres's ArrayField
"""
RED = "r"
YELLOW = "y"
GREEN = "g"
STATUS_CHOICES = (
(RED, "Red"),
(YELLOW, "Yellow"),
(GREEN, "Green"),
)
arrayfield = ArrayField(
models.CharField(max_length=1, choices=STATUS_CHOICES), size=3
)
history = AuditlogHistoryField()
class NoDeleteHistoryModel(models.Model):
integer = models.IntegerField(blank=True, null=True)
history = AuditlogHistoryField(delete_related=False)
auditlog.register(AltPrimaryKeyModel)
auditlog.register(UUIDPrimaryKeyModel)
auditlog.register(ProxyModel)
auditlog.register(RelatedModel)
auditlog.register(ManyRelatedModel)
auditlog.register(ManyRelatedModel.related.through)
auditlog.register(SimpleExcludeModel, exclude_fields=["text"])
auditlog.register(SimpleMappingModel, mapping_fields={"sku": "Product No."})
auditlog.register(AdditionalDataIncludedModel)
auditlog.register(DateTimeFieldModel)
auditlog.register(ChoicesFieldModel)
auditlog.register(CharfieldTextfieldModel)
auditlog.register(PostgresArrayFieldModel)
auditlog.register(NoDeleteHistoryModel)

View file

@ -1,2 +0,0 @@
def get_cid():
return "my custom get_cid"

View file

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

View file

@ -1,6 +0,0 @@
def custom_mask_str(value: str) -> str:
"""Custom masking function that only shows the last 4 characters."""
if len(value) > 4:
return "****" + value[-4:]
return value

View file

@ -1,532 +0,0 @@
import uuid
from django.conf import settings
from django.core.serializers.json import DjangoJSONEncoder
from django.db import models
from auditlog.models import AuditlogHistoryField
from auditlog.registry import AuditlogModelRegistry, auditlog
m2m_only_auditlog = AuditlogModelRegistry(create=False, update=False, delete=False)
@auditlog.register()
class SimpleModel(models.Model):
"""
A simple model with no special things going on.
"""
text = models.TextField(blank=True)
boolean = models.BooleanField(default=False)
integer = models.IntegerField(blank=True, null=True)
datetime = models.DateTimeField(auto_now=True)
char = models.CharField(null=True, max_length=100, default=lambda: "default value")
history = AuditlogHistoryField(delete_related=True)
def __str__(self):
return str(self.text)
class AltPrimaryKeyModel(models.Model):
"""
A model with a non-standard primary key.
"""
key = models.CharField(max_length=100, primary_key=True)
text = models.TextField(blank=True)
boolean = models.BooleanField(default=False)
integer = models.IntegerField(blank=True, null=True)
datetime = models.DateTimeField(auto_now=True)
history = AuditlogHistoryField(delete_related=True, pk_indexable=False)
class UUIDPrimaryKeyModel(models.Model):
"""
A model with a UUID primary key.
"""
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
text = models.TextField(blank=True)
boolean = models.BooleanField(default=False)
integer = models.IntegerField(blank=True, null=True)
datetime = models.DateTimeField(auto_now=True)
history = AuditlogHistoryField(delete_related=True, pk_indexable=False)
class ModelPrimaryKeyModel(models.Model):
"""
A model with another model as primary key.
"""
key = models.OneToOneField(
"SimpleModel",
primary_key=True,
on_delete=models.CASCADE,
related_name="reverse_primary_key",
)
text = models.TextField(blank=True)
boolean = models.BooleanField(default=False)
integer = models.IntegerField(blank=True, null=True)
datetime = models.DateTimeField(auto_now=True)
history = AuditlogHistoryField(delete_related=True, pk_indexable=False)
class ProxyModel(SimpleModel):
"""
A model that is a proxy for another model.
"""
class Meta:
proxy = True
class RelatedModelParent(models.Model):
"""
Use multi table inheritance to make a OneToOneRel field
"""
class RelatedModel(RelatedModelParent):
"""
A model with a foreign key.
"""
related = models.ForeignKey(
"SimpleModel", related_name="related_models", on_delete=models.CASCADE
)
one_to_one = models.OneToOneField(
to="SimpleModel", on_delete=models.CASCADE, related_name="reverse_one_to_one"
)
history = AuditlogHistoryField(delete_related=True)
def __str__(self):
return f"RelatedModel #{self.pk} -> {self.related.id}"
class ManyRelatedModel(models.Model):
"""
A model with many-to-many relations.
"""
recursive = models.ManyToManyField("self")
related = models.ManyToManyField("ManyRelatedOtherModel", related_name="related")
history = AuditlogHistoryField(delete_related=True)
def get_additional_data(self):
related = self.related.first()
return {"related_model_id": related.id if related else None}
class ManyRelatedOtherModel(models.Model):
"""
A model related to ManyRelatedModel as many-to-many.
"""
history = AuditlogHistoryField(delete_related=True)
class ReusableThroughRelatedModel(models.Model):
"""
A model related to multiple other models through a model.
"""
label = models.CharField(max_length=100)
class ReusableThroughModel(models.Model):
"""
A through model that can be associated multiple different models.
"""
label = models.ForeignKey(
ReusableThroughRelatedModel,
on_delete=models.CASCADE,
related_name="%(app_label)s_%(class)s_items",
)
one = models.ForeignKey(
"ModelForReusableThroughModel", on_delete=models.CASCADE, null=True, blank=True
)
two = models.ForeignKey(
"OtherModelForReusableThroughModel",
on_delete=models.CASCADE,
null=True,
blank=True,
)
class ModelForReusableThroughModel(models.Model):
"""
A model with many-to-many relations through a shared model.
"""
name = models.CharField(max_length=200)
related = models.ManyToManyField(
ReusableThroughRelatedModel, through=ReusableThroughModel
)
history = AuditlogHistoryField(delete_related=True)
class OtherModelForReusableThroughModel(models.Model):
"""
Another model with many-to-many relations through a shared model.
"""
name = models.CharField(max_length=200)
related = models.ManyToManyField(
ReusableThroughRelatedModel, through=ReusableThroughModel
)
history = AuditlogHistoryField(delete_related=True)
@auditlog.register(include_fields=["label"])
class SimpleIncludeModel(models.Model):
"""
A simple model used for register's include_fields kwarg
"""
label = models.CharField(max_length=100)
text = models.TextField(blank=True)
history = AuditlogHistoryField(delete_related=True)
class SimpleExcludeModel(models.Model):
"""
A simple model used for register's exclude_fields kwarg
"""
label = models.CharField(max_length=100)
text = models.TextField(blank=True)
history = AuditlogHistoryField(delete_related=True)
class SimpleMappingModel(models.Model):
"""
A simple model used for register's mapping_fields kwarg
"""
sku = models.CharField(max_length=100)
vtxt = models.CharField(verbose_name="Version", max_length=100)
not_mapped = models.CharField(max_length=100)
history = AuditlogHistoryField(delete_related=True)
@auditlog.register(mask_fields=["address"])
class SimpleMaskedModel(models.Model):
"""
A simple model used for register's mask_fields kwarg
"""
address = models.CharField(max_length=100)
text = models.TextField()
history = AuditlogHistoryField(delete_related=True)
class AdditionalDataIncludedModel(models.Model):
"""
A model where get_additional_data is defined which allows for logging extra
information about the model in JSON
"""
label = models.CharField(max_length=100)
text = models.TextField(blank=True)
related = models.ForeignKey(to=SimpleModel, on_delete=models.CASCADE)
history = AuditlogHistoryField(delete_related=True)
def get_additional_data(self):
"""
Returns JSON that captures a snapshot of additional details of the
model instance. This method, if defined, is accessed by auditlog
manager and added to each logentry instance on creation.
"""
object_details = {
"related_model_id": self.related.id,
"related_model_text": self.related.text,
}
return object_details
class DateTimeFieldModel(models.Model):
"""
A model with a DateTimeField, used to test DateTimeField
changes are detected properly.
"""
label = models.CharField(max_length=100)
timestamp = models.DateTimeField()
date = models.DateField()
time = models.TimeField()
naive_dt = models.DateTimeField(null=True, blank=True)
history = AuditlogHistoryField(delete_related=True)
class ChoicesFieldModel(models.Model):
"""
A model with a CharField restricted to a set of choices.
This model is used to test the changes_display_dict method.
"""
RED = "r"
YELLOW = "y"
GREEN = "g"
STATUS_CHOICES = (
(RED, "Red"),
(YELLOW, "Yellow"),
(GREEN, "Green"),
)
status = models.CharField(max_length=1, choices=STATUS_CHOICES)
multiplechoice = models.CharField(max_length=255, choices=STATUS_CHOICES)
history = AuditlogHistoryField(delete_related=True)
class CharfieldTextfieldModel(models.Model):
"""
A model with a max length CharField and a Textfield.
This model is used to test the changes_display_dict
method's ability to truncate long text.
"""
longchar = models.CharField(max_length=255)
longtextfield = models.TextField()
history = AuditlogHistoryField(delete_related=True)
# Only define PostgreSQL-specific models when ArrayField is available
if settings.TEST_DB_BACKEND == "postgresql":
from django.contrib.postgres.fields import ArrayField
class PostgresArrayFieldModel(models.Model):
"""
Test auditlog with Postgres's ArrayField
"""
RED = "r"
YELLOW = "y"
GREEN = "g"
STATUS_CHOICES = (
(RED, "Red"),
(YELLOW, "Yellow"),
(GREEN, "Green"),
)
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):
integer = models.IntegerField(blank=True, null=True)
history = AuditlogHistoryField(delete_related=False)
class JSONModel(models.Model):
json = models.JSONField(default=dict, encoder=DjangoJSONEncoder)
history = AuditlogHistoryField(delete_related=False)
class NullableJSONModel(models.Model):
json = models.JSONField(null=True, blank=True)
history = AuditlogHistoryField(delete_related=False)
class SerializeThisModel(models.Model):
label = models.CharField(max_length=24, unique=True)
timestamp = models.DateTimeField()
nullable = models.IntegerField(null=True)
nested = models.JSONField()
mask_me = models.CharField(max_length=255, null=True)
code = models.UUIDField(null=True)
date = models.DateField(null=True)
history = AuditlogHistoryField(delete_related=False)
def natural_key(self):
return self.label
class SerializeOnlySomeOfThisModel(models.Model):
this = models.CharField(max_length=24)
not_this = models.CharField(max_length=24)
history = AuditlogHistoryField(delete_related=False)
class SerializePrimaryKeyRelatedModel(models.Model):
serialize_this = models.ForeignKey(to=SerializeThisModel, on_delete=models.CASCADE)
subheading = models.CharField(max_length=255)
value = models.IntegerField()
history = AuditlogHistoryField(delete_related=False)
class SerializeNaturalKeyRelatedModel(models.Model):
serialize_this = models.ForeignKey(to=SerializeThisModel, on_delete=models.CASCADE)
subheading = models.CharField(max_length=255)
value = models.IntegerField()
history = AuditlogHistoryField(delete_related=False)
class SimpleNonManagedModel(models.Model):
"""
A simple model with no special things going on.
"""
text = models.TextField(blank=True)
boolean = models.BooleanField(default=False)
integer = models.IntegerField(blank=True, null=True)
datetime = models.DateTimeField(auto_now=True)
history = AuditlogHistoryField(delete_related=True)
def __str__(self):
return self.text
class Meta:
managed = False
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()
def __str__(self):
return str(self.name)
@auditlog.register()
class SecretRelatedModel(RelatedModelParent):
"""
A RelatedModel, but with a foreign key to an object that could be secret.
"""
related = models.ForeignKey(
"SwappedManagerModel", related_name="related_models", on_delete=models.CASCADE
)
one_to_one = models.OneToOneField(
to="SwappedManagerModel",
on_delete=models.CASCADE,
related_name="reverse_one_to_one",
)
history = AuditlogHistoryField(delete_related=True)
def __str__(self):
return f"SecretRelatedModel #{self.pk} -> {self.related.id}"
class SecretM2MModel(models.Model):
m2m_related = models.ManyToManyField(
"SwappedManagerModel", related_name="m2m_related"
)
name = models.CharField(max_length=255)
def __str__(self):
return str(self.name)
class AutoManyRelatedModel(models.Model):
related = models.ManyToManyField(SimpleModel)
class CustomMaskModel(models.Model):
credit_card = models.CharField(max_length=16)
text = models.TextField()
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(UUIDPrimaryKeyModel)
auditlog.register(ModelPrimaryKeyModel)
auditlog.register(ProxyModel)
auditlog.register(RelatedModelParent)
auditlog.register(RelatedModel)
auditlog.register(ManyRelatedModel)
auditlog.register(ManyRelatedModel.recursive.through)
m2m_only_auditlog.register(ManyRelatedModel, m2m_fields={"related"})
m2m_only_auditlog.register(ModelForReusableThroughModel, m2m_fields={"related"})
m2m_only_auditlog.register(OtherModelForReusableThroughModel, m2m_fields={"related"})
m2m_only_auditlog.register(SecretM2MModel, m2m_fields={"m2m_related"})
m2m_only_auditlog.register(SwappedManagerModel, m2m_fields={"m2m_related"})
auditlog.register(SimpleExcludeModel, exclude_fields=["text"])
auditlog.register(SimpleMappingModel, mapping_fields={"sku": "Product No."})
auditlog.register(AdditionalDataIncludedModel)
auditlog.register(DateTimeFieldModel)
auditlog.register(ChoicesFieldModel)
auditlog.register(CharfieldTextfieldModel)
if settings.TEST_DB_BACKEND == "postgresql":
auditlog.register(PostgresArrayFieldModel)
auditlog.register(NoDeleteHistoryModel)
auditlog.register(JSONModel)
auditlog.register(NullableJSONModel)
auditlog.register(
SerializeThisModel,
serialize_data=True,
mask_fields=["mask_me"],
)
auditlog.register(
SerializeOnlySomeOfThisModel,
serialize_data=True,
serialize_auditlog_fields_only=True,
exclude_fields=["not_this"],
)
auditlog.register(SerializePrimaryKeyRelatedModel, serialize_data=True)
auditlog.register(
SerializeNaturalKeyRelatedModel,
serialize_data=True,
serialize_kwargs={"use_natural_foreign_keys": True},
)
auditlog.register(
CustomMaskModel,
mask_fields=["credit_card"],
mask_callable="auditlog_tests.test_app.mask.custom_mask_str",
)
auditlog.register(NullableFieldModel)

View file

@ -1,13 +0,0 @@
from django.contrib import admin
from django.urls import path
from .views import SimpleModelDetailView
urlpatterns = [
path("admin/", admin.site.urls),
path(
"simplemodel/<int:pk>/",
SimpleModelDetailView.as_view(),
name="simplemodel-detail",
),
]

View file

@ -1,10 +0,0 @@
from django.views.generic import DetailView
from auditlog.mixins import LogAccessMixin
from .models import SimpleModel
class SimpleModelDetailView(LogAccessMixin, DetailView):
model = SimpleModel
template_name = "simplemodel_detail.html"

View file

@ -1,205 +0,0 @@
"""Tests for auditlog.management.commands"""
import datetime
from io import StringIO
from unittest import mock
import freezegun
from django.core.management import call_command
from django.db import connection
from django.test import TestCase, TransactionTestCase
from django.test.utils import skipIf
from test_app.models import SimpleModel
from auditlog.management.commands.auditlogflush import TruncateQuery
class AuditlogFlushTest(TestCase):
def setUp(self):
input_patcher = mock.patch("builtins.input")
self.mock_input = input_patcher.start()
self.addCleanup(input_patcher.stop)
def make_object(self):
return SimpleModel.objects.create(text="I am a simple model.")
def call_command(self, *args, **kwargs):
outbuf = StringIO()
errbuf = StringIO()
call_command("auditlogflush", *args, stdout=outbuf, stderr=errbuf, **kwargs)
return outbuf.getvalue().strip(), errbuf.getvalue().strip()
def test_flush_yes(self):
obj = self.make_object()
self.assertEqual(obj.history.count(), 1, msg="There is one log entry.")
out, err = self.call_command("--yes")
self.assertEqual(obj.history.count(), 0, msg="There are no log entries.")
self.assertEqual(
out, "Deleted 1 objects.", msg="Output shows deleted 1 object."
)
self.assertEqual(err, "", msg="No stderr")
def test_flush_no(self):
obj = self.make_object()
self.assertEqual(obj.history.count(), 1, msg="There is one log entry.")
self.mock_input.return_value = "N\n"
out, err = self.call_command()
self.assertEqual(obj.history.count(), 1, msg="There is still one log entry.")
self.assertEqual(
out,
"This action will clear all log entries from the database.\nAborted.",
msg="Output shows warning and aborted.",
)
self.assertEqual(err, "", msg="No stderr")
def test_flush_input_yes(self):
obj = self.make_object()
self.assertEqual(obj.history.count(), 1, msg="There is one log entry.")
self.mock_input.return_value = "Y\n"
out, err = self.call_command()
self.assertEqual(obj.history.count(), 0, msg="There are no log entries.")
self.assertEqual(
out,
"This action will clear all log entries from the database.\nDeleted 1 objects.",
msg="Output shows warning and deleted 1 object.",
)
self.assertEqual(err, "", msg="No stderr")
def test_before_date_input(self):
self.mock_input.return_value = "N\n"
out, err = self.call_command("--before-date=2000-01-01")
self.assertEqual(
out,
(
"This action will clear all log entries before "
"2000-01-01 from the database.\nAborted."
),
msg="Output shows warning with date and then aborted.",
)
self.assertEqual(err, "", msg="No stderr")
def test_before_date(self):
with freezegun.freeze_time("1999-12-31"):
obj = self.make_object()
with freezegun.freeze_time("2000-01-02"):
obj.text = "I have new text"
obj.save()
self.assertEqual(
{v["timestamp"] for v in obj.history.values("timestamp")},
{
datetime.datetime(1999, 12, 31, tzinfo=datetime.timezone.utc),
datetime.datetime(2000, 1, 2, tzinfo=datetime.timezone.utc),
},
msg="Entries exist for 1999-12-31 and 2000-01-02",
)
out, err = self.call_command("--yes", "--before-date=2000-01-01")
self.assertEqual(
{v["timestamp"] for v in obj.history.values("timestamp")},
{
datetime.datetime(2000, 1, 2, tzinfo=datetime.timezone.utc),
},
msg="An entry exists only for 2000-01-02",
)
self.assertEqual(
out, "Deleted 1 objects.", msg="Output shows deleted 1 object."
)
self.assertEqual(err, "", msg="No stderr")
class AuditlogFlushWithTruncateTest(TransactionTestCase):
def setUp(self):
input_patcher = mock.patch("builtins.input")
self.mock_input = input_patcher.start()
self.addCleanup(input_patcher.stop)
def _fixture_teardown(self):
call_command("flush", verbosity=0, interactive=False, allow_cascade=True)
def make_object(self):
return SimpleModel.objects.create(text="I am a simple model.")
def call_command(self, *args, **kwargs):
outbuf = StringIO()
errbuf = StringIO()
call_command("auditlogflush", *args, stdout=outbuf, stderr=errbuf, **kwargs)
return outbuf.getvalue().strip(), errbuf.getvalue().strip()
def test_flush_with_both_truncate_and_before_date_options(self):
obj = self.make_object()
self.assertEqual(obj.history.count(), 1, msg="There is one log entry.")
out, err = self.call_command("--truncate", "--before-date=2000-01-01")
self.assertEqual(obj.history.count(), 1, msg="There is still one log entry.")
self.assertEqual(
out,
"Truncate deletes all log entries and can not be passed with before-date.",
msg="Output shows error",
)
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):
obj = self.make_object()
self.assertEqual(obj.history.count(), 1, msg="There is one log entry.")
out, err = self.call_command("--truncate", "--y")
self.assertEqual(obj.history.count(), 0, msg="There is no log entry.")
self.assertEqual(
out,
"Truncated log entry table.",
msg="Output shows table gets truncate",
)
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):
obj = self.make_object()
self.assertEqual(obj.history.count(), 1, msg="There is one log entry.")
self.mock_input.return_value = "Y\n"
out, err = self.call_command("--truncate")
self.assertEqual(obj.history.count(), 0, msg="There is no log entry.")
self.assertEqual(
out,
"This action will clear all log entries from the database.\nTruncated log entry table.",
msg="Output shows warning and table gets truncate",
)
self.assertEqual(err, "", msg="No stderr")
@mock.patch(
"django.db.connection.vendor",
new_callable=mock.PropertyMock(return_value="unknown"),
)
@mock.patch(
"django.db.connection.display_name",
new_callable=mock.PropertyMock(return_value="Unknown"),
)
def test_flush_with_truncate_for_unsupported_database_vendor(
self, mocked_vendor, mocked_db_name
):
obj = self.make_object()
self.assertEqual(obj.history.count(), 1, msg="There is one log entry.")
out, err = self.call_command("--truncate", "--y")
self.assertEqual(obj.history.count(), 1, msg="There is still one log entry.")
self.assertEqual(
out,
"Database Unknown does not support truncate statement.",
msg="Output shows error",
)
self.assertEqual(err, "", msg="No stderr")

View file

@ -1,51 +0,0 @@
"""
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

@ -1,167 +0,0 @@
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

@ -1,15 +1,12 @@
"""
Settings file for the Auditlog test suite.
"""
import os
DEBUG = True
SECRET_KEY = "test"
TEST_DB_BACKEND = os.getenv("TEST_DB_BACKEND", "sqlite3")
INSTALLED_APPS = [
"django.contrib.auth",
"django.contrib.contenttypes",
@ -17,72 +14,30 @@ INSTALLED_APPS = [
"django.contrib.sessions",
"django.contrib.admin",
"django.contrib.staticfiles",
"django.contrib.postgres",
"custom_logentry_app",
"auditlog",
"test_app",
"auditlog_tests",
]
MIDDLEWARE = [
"django.middleware.common.CommonMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"auditlog.middleware.AuditlogMiddleware",
]
if os.environ.get("AUDITLOG_LOGENTRY_MODEL", None):
MIDDLEWARE = MIDDLEWARE + ["auditlog.middleware.AuditlogMiddleware"]
else:
MIDDLEWARE = MIDDLEWARE + ["middleware.CustomAuditlogMiddleware"]
if TEST_DB_BACKEND == "postgresql":
DATABASES = {
"default": {
"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"),
}
DATABASES = {
"default": {
"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 = [
{
@ -101,10 +56,8 @@ TEMPLATES = [
STATIC_URL = "/static/"
ROOT_URLCONF = "test_app.urls"
ROOT_URLCONF = "auditlog_tests.urls"
USE_TZ = True
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
AUDITLOG_LOGENTRY_MODEL = os.environ.get("AUDITLOG_LOGENTRY_MODEL", "auditlog.LogEntry")

View file

@ -1,198 +0,0 @@
import json
from io import StringIO
from unittest.mock import patch
from django.conf import settings
from django.core.management import CommandError, call_command
from django.test import TestCase, override_settings
from django.test.utils import skipIf
from test_app.models import SimpleModel
from auditlog import get_logentry_model
LogEntry = get_logentry_model()
class TwoStepMigrationTest(TestCase):
def test_use_text_changes_first(self):
text_obj = '{"field": "changes_text"}'
json_obj = {"field": "changes"}
_params = [
(True, None, text_obj, {"field": "changes_text"}),
(True, json_obj, text_obj, json_obj),
(True, None, "not json", {}),
(False, json_obj, text_obj, json_obj),
]
for setting_value, changes_value, changes_text_value, expected in _params:
with self.subTest():
entry = LogEntry(changes=changes_value, changes_text=changes_text_value)
with self.settings(
AUDITLOG_USE_TEXT_CHANGES_IF_JSON_IS_NOT_PRESENT=setting_value
):
from auditlog import models
changes_dict = models._changes_func()(entry)
self.assertEqual(changes_dict, expected)
class AuditlogMigrateJsonTest(TestCase):
def make_logentry(self):
model = SimpleModel.objects.create(text="I am a simple model.")
log_entry: LogEntry = model.history.first()
log_entry.changes_text = json.dumps(log_entry.changes)
log_entry.changes = None
log_entry.save()
return log_entry
def call_command(self, *args, **kwargs):
outbuf = StringIO()
errbuf = StringIO()
args = ("--no-color",) + args
call_command(
"auditlogmigratejson", *args, stdout=outbuf, stderr=errbuf, **kwargs
)
return outbuf.getvalue().strip(), errbuf.getvalue().strip()
def test_nothing_to_migrate(self):
outbuf, errbuf = self.call_command()
msg = "All records have been migrated."
self.assertEqual(outbuf, msg)
@override_settings(AUDITLOG_USE_TEXT_CHANGES_IF_JSON_IS_NOT_PRESENT=True)
def test_nothing_to_migrate_with_conf_true(self):
outbuf, errbuf = self.call_command()
msg = (
"All records have been migrated.\n"
"You can now set AUDITLOG_USE_TEXT_CHANGES_IF_JSON_IS_NOT_PRESENT to False."
)
self.assertEqual(outbuf, msg)
def test_check(self):
# Arrange
log_entry = self.make_logentry()
# Act
outbuf, errbuf = self.call_command("--check")
log_entry.refresh_from_db()
# Assert
self.assertEqual("There are 1 records that needs migration.", outbuf)
self.assertEqual("", errbuf)
self.assertIsNone(log_entry.changes)
def test_using_django(self):
# Arrange
log_entry = self.make_logentry()
# Act
outbuf, errbuf = self.call_command("-b=0")
log_entry.refresh_from_db()
# Assert
self.assertEqual(errbuf, "")
self.assertIsNotNone(log_entry.changes)
def test_using_django_batched(self):
# Arrange
log_entry_1 = self.make_logentry()
log_entry_2 = self.make_logentry()
# Act
outbuf, errbuf = self.call_command("-b=1")
log_entry_1.refresh_from_db()
log_entry_2.refresh_from_db()
# Assert
self.assertEqual(errbuf, "")
self.assertIsNotNone(log_entry_1.changes)
self.assertIsNotNone(log_entry_2.changes)
def test_using_django_batched_call_count(self):
"""
This is split into a different test because I couldn't figure out how to properly patch bulk_update.
For some reason, then I
"""
# Arrange
self.make_logentry()
self.make_logentry()
# Act
LogEntry = get_logentry_model()
path = f"{LogEntry.__module__}.{LogEntry.__name__}.objects.bulk_update"
with patch(path) as bulk_update:
outbuf, errbuf = self.call_command("-b=1")
call_count = bulk_update.call_count
# Assert
self.assertEqual(call_count, 2)
@skipIf(settings.TEST_DB_BACKEND != "postgresql", "PostgreSQL-specific test")
def test_native_postgres(self):
# Arrange
log_entry = self.make_logentry()
# Act
outbuf, errbuf = self.call_command("-d=postgres")
log_entry.refresh_from_db()
# Assert
self.assertEqual(errbuf, "")
self.assertIsNotNone(log_entry.changes)
@skipIf(settings.TEST_DB_BACKEND != "postgresql", "PostgreSQL-specific test")
def test_native_postgres_changes_not_overwritten(self):
# Arrange
log_entry = self.make_logentry()
log_entry.changes = original_changes = {"key": "value"}
log_entry.changes_text = '{"key": "new value"}'
log_entry.save()
# Act
outbuf, errbuf = self.call_command("-d=postgres")
log_entry.refresh_from_db()
# Assert
self.assertEqual(errbuf, "")
self.assertEqual(log_entry.changes, original_changes)
def test_native_unsupported(self):
# Arrange
log_entry = self.make_logentry()
msg = (
"Migrating the records using oracle is not implemented. "
"Run this management command without passing a -d/--database argument."
)
# Act
with self.assertRaises(CommandError) as cm:
self.call_command("-d=oracle")
log_entry.refresh_from_db()
# Assert
self.assertEqual(msg, cm.exception.args[0])
self.assertIsNone(log_entry.changes)
def test_using_django_with_error(self):
# Arrange
log_entry = self.make_logentry()
log_entry.changes_text = "not json"
log_entry.save()
# Act
outbuf, errbuf = self.call_command()
log_entry.refresh_from_db()
# Assert
msg = (
f"ValueError was raised while converting the logs with these ids into json."
f"They where not be included in this migration batch."
f"\n"
f"{[log_entry.id]}"
)
self.assertEqual(msg, errbuf)
self.assertIsNone(log_entry.changes)

View file

@ -1,198 +0,0 @@
from django.test import TestCase, override_settings
from test_app.models import JSONModel, NullableFieldModel, RelatedModel, SimpleModel
from auditlog import get_logentry_model
from auditlog.registry import AuditlogModelRegistry
LogEntry = get_logentry_model()
class JSONForChangesTest(TestCase):
def setUp(self):
self.test_auditlog = AuditlogModelRegistry()
@override_settings(AUDITLOG_STORE_JSON_CHANGES="str")
def test_wrong_setting_type(self):
with self.assertRaisesMessage(
TypeError, "Setting 'AUDITLOG_STORE_JSON_CHANGES' must be a boolean"
):
self.test_auditlog.register_from_settings()
@override_settings(AUDITLOG_STORE_JSON_CHANGES=True)
def test_use_json_for_changes_with_simplemodel(self):
self.test_auditlog.register_from_settings()
smm = SimpleModel()
smm.save()
changes_dict = smm.history.latest().changes_dict
# compare the id, text, boolean and datetime fields
id_field_changes = changes_dict["id"]
self.assertIsNone(id_field_changes[0])
self.assertIsInstance(
id_field_changes[1], int
) # the id depends on state of the database
text_field_changes = changes_dict["text"]
self.assertEqual(text_field_changes, [None, ""])
boolean_field_changes = changes_dict["boolean"]
self.assertEqual(boolean_field_changes, [None, False])
# datetime should be serialized to string
datetime_field_changes = changes_dict["datetime"]
self.assertIsNone(datetime_field_changes[0])
self.assertIsInstance(datetime_field_changes[1], str)
@override_settings(AUDITLOG_STORE_JSON_CHANGES=True)
def test_use_json_for_changes_with_jsonmodel(self):
self.test_auditlog.register_from_settings()
json_model = JSONModel()
json_model.json = {"test_key": "test_value"}
json_model.save()
changes_dict = json_model.history.latest().changes_dict
id_field_changes = changes_dict["json"]
self.assertEqual(id_field_changes, [None, {"test_key": "test_value"}])
@override_settings(AUDITLOG_STORE_JSON_CHANGES=True)
def test_use_json_for_changes_with_jsonmodel_with_empty_list(self):
self.test_auditlog.register_from_settings()
json_model = JSONModel()
json_model.json = []
json_model.save()
changes_dict = json_model.history.latest().changes_dict
id_field_changes = changes_dict["json"]
self.assertEqual(id_field_changes, [None, []])
@override_settings(AUDITLOG_STORE_JSON_CHANGES=True)
def test_use_json_for_changes_with_jsonmodel_with_complex_data(self):
self.test_auditlog.register_from_settings()
json_model = JSONModel()
json_model.json = {
"key": "test_value",
"key_dict": {"inner_key": "inner_value"},
"key_tuple": ("item1", "item2", "item3"),
}
json_model.save()
changes_dict = json_model.history.latest().changes_dict
id_field_changes = changes_dict["json"]
self.assertEqual(
id_field_changes,
[
None,
{
"key": "test_value",
"key_dict": {"inner_key": "inner_value"},
"key_tuple": [
"item1",
"item2",
"item3",
], # tuple is converted to list, that's ok
},
],
)
@override_settings(AUDITLOG_STORE_JSON_CHANGES=True)
def test_use_json_for_changes_with_jsonmodel_with_related_model(self):
self.test_auditlog.register_from_settings()
simple = SimpleModel.objects.create()
one_simple = SimpleModel.objects.create()
related_model = RelatedModel.objects.create(
one_to_one=simple, related=one_simple
)
related_model.save()
changes_dict = related_model.history.latest().changes_dict
field_related_changes = changes_dict["related"]
self.assertEqual(field_related_changes, [None, one_simple.id])
field_one_to_one_changes = changes_dict["one_to_one"]
self.assertEqual(field_one_to_one_changes, [None, simple.id])
@override_settings(AUDITLOG_STORE_JSON_CHANGES=True)
def test_use_json_for_changes_update(self):
self.test_auditlog.register_from_settings()
simple = SimpleModel(text="original")
simple.save()
simple.text = "new"
simple.save()
changes_dict = simple.history.latest().changes_dict
text_changes = changes_dict["text"]
self.assertEqual(text_changes, ["original", "new"])
@override_settings(AUDITLOG_STORE_JSON_CHANGES=True)
def test_use_json_for_changes_delete(self):
self.test_auditlog.register_from_settings()
simple = SimpleModel()
simple.save()
simple.delete()
history = LogEntry.objects.all()
self.assertEqual(history.count(), 1, '"DELETE" record is always retained')
changes_dict = history.first().changes_dict
self.assertTrue(
all(v[1] is None for k, v in changes_dict.items()),
'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"])

View file

@ -1,175 +0,0 @@
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])

File diff suppressed because it is too large Load diff

6
auditlog_tests/urls.py Normal file
View file

@ -0,0 +1,6 @@
from django.contrib import admin
from django.urls import path
urlpatterns = [
path("admin/", admin.site.urls),
]

View file

@ -21,7 +21,7 @@ if errorlevel 9009 (
echo.may add the Sphinx directory to PATH.
echo.
echo.If you don't have Sphinx installed, grab it from
echo.https://sphinx-doc.org/
echo.http://sphinx-doc.org/
exit /b 1
)

View file

@ -1,6 +1,4 @@
# Docs requirements
django>=4.2,<4.3
sphinx
sphinx_rtd_theme
psycopg2-binary
mysqlclient==2.2.5

View file

@ -10,7 +10,7 @@ import os
import sys
from datetime import date
from auditlog import __version__
from pkg_resources import get_distribution
# -- Path setup --------------------------------------------------------------
@ -19,12 +19,11 @@ from auditlog import __version__
# documentation root, use os.path.abspath to make it absolute, like shown here.
# Add sources folder
sys.path.insert(0, os.path.abspath("../../auditlog_tests"))
sys.path.insert(0, os.path.abspath("../../"))
# Setup Django for autodoc
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_settings")
import django # noqa: E402
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "auditlog_tests.test_settings")
import django
django.setup()
@ -34,7 +33,7 @@ project = "django-auditlog"
author = "Jan-Jelle Kester and contributors"
copyright = f"2013-{date.today().year}, {author}"
release = __version__
release = get_distribution("django-auditlog").version
# for example take major/minor
version = ".".join(release.split(".")[:2])

View file

@ -21,7 +21,6 @@ Contents
installation
usage
upgrade
internals

View file

@ -11,10 +11,10 @@ The repository can be found at https://github.com/jazzband/django-auditlog/.
**Requirements**
- Python 3.10 or higher
- Django 4.2, 5.0, 5.1, and 5.2
- Python 3.6 or higher
- Django 2.2 or higher
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
Auditlog is currently tested with Python 3.6 - 3.8 and Django 2.2, 3.1 and 3.2. The latest test report can be found
at https://github.com/jazzband/django-auditlog/actions.
Adding Auditlog to your Django application

View file

@ -19,30 +19,12 @@ Middleware
.. automodule:: auditlog.middleware
:members: AuditlogMiddleware
Correlation ID
--------------
.. automodule:: auditlog.cid
:members: get_cid, set_cid
Signal receivers
----------------
.. automodule:: auditlog.receivers
:members:
Custom Signals
--------------
Django Auditlog provides two custom signals that will hook in before
and after any Auditlog record is written from a ``create``, ``update``,
``delete``, or ``accessed`` action on an audited model.
.. automodule:: auditlog.signals
:members:
:member-order: bysource
.. versionadded:: 3.0.0
Calculating changes
-------------------

View file

@ -1,21 +0,0 @@
Upgrading to version 3
======================
Version 3.0.0 introduces breaking changes. Please review the migration guide below before upgrading.
If you're new to django-auditlog, you can ignore this part.
The major change in the version is that we're finally storing changes as json instead of json-text.
To convert the existing records, this version has a database migration that does just that.
However, this migration will take a long time if you have a huge amount of records,
causing your database and application to be out of sync until the migration is complete.
To avoid this, follow these steps:
1. Before upgrading the package, add these two variables to ``settings.py``:
* ``AUDITLOG_TWO_STEP_MIGRATION = True``
* ``AUDITLOG_USE_TEXT_CHANGES_IF_JSON_IS_NOT_PRESENT = True``
2. Upgrade the package. Your app will now start storing new records as JSON, but the old records will accessible via ``LogEntry.changes_text``.
3. Use the newly added ``auditlogmigratejson`` command to migrate your records. Run ``django-admin auditlogmigratejson --help`` to get more information.
4. Once all records are migrated, remove the variables listed above, or set their values to ``False``.

View file

@ -11,21 +11,16 @@ even more convenience, :py:class:`LogEntryManager` provides a number of methods
See :doc:`internals` for all details.
.. _Automatically logging changes:
Automatically logging changes
-----------------------------
Auditlog can automatically log changes to objects for you. This functionality is based on Django's signals, but linking
your models to Auditlog is even easier than using signals.
Registering your model for logging can be done with a single line of code, as the following example illustrates:
.. code-block:: python
from django.db import models
Registering your model for logging can be done with a single line of code, as the following example illustrates::
from auditlog.registry import auditlog
from django.db import models
class MyModel(models.Model):
pass
@ -37,46 +32,11 @@ It is recommended to place the register code (``auditlog.register(MyModel)``) at
This ensures that every time your model is imported it will also be registered to log changes. Auditlog makes sure that
each model is only registered once, otherwise duplicate log entries would occur.
**Logging access**
By default, Auditlog will only log changes to your model instances. If you want to log access to your model instances as well, Auditlog provides a mixin class for that purpose. Simply add the :py:class:`auditlog.mixins.LogAccessMixin` to your class based view and Auditlog will log access to your model instances. The mixin expects your view to have a ``get_object`` method that returns the model instance for which access shall be logged - this is usually the case for DetailViews and UpdateViews.
A DetailView utilizing the LogAccessMixin could look like the following example:
.. code-block:: python
from django.views.generic import DetailView
from auditlog.mixins import LogAccessMixin
class MyModelDetailView(LogAccessMixin, DetailView):
model = MyModel
# View code goes here
You can also add log-access to function base views, as the following example illustrates:
.. code-block:: python
from auditlog.signals import accessed
def profile_view(request, pk):
## get the object you want to log access
user = User.objects.get(pk=pk)
## log access
accessed.send(user.__class__, instance=user)
# View code goes here
...
**Excluding fields**
Fields that are excluded will not trigger saving a new log entry and will not show up in the recorded changes.
To exclude specific fields from the log you can pass ``include_fields`` or ``exclude_fields`` to the ``register``
To exclude specific fields from the log you can pass ``include_fields`` resp. ``exclude_fields`` to the ``register``
method. If ``exclude_fields`` is specified the fields with the given names will not be included in the generated log
entries. If ``include_fields`` is specified only the fields with the given names will be included in the generated log
entries. Explicitly excluding fields through ``exclude_fields`` takes precedence over specifying which fields to
@ -96,20 +56,15 @@ If you have field names on your models that aren't intuitive or user friendly yo
during the `register()` call.
.. code-block:: python
from django.db import models
from auditlog.models import AuditlogHistoryField
from auditlog.registry import auditlog
class MyModel(models.Model):
class MyModel(modelsModel):
sku = models.CharField(max_length=20)
version = models.CharField(max_length=5)
product = models.CharField(max_length=50, verbose_name='Product Name')
history = AuditlogHistoryField()
history = AuditLogHistoryField()
auditlog.register(MyModel, mapping_fields={'sku': 'Product No.', 'version': 'Product Revision'})
.. code-block:: python
log = MyModel.objects.first().history.latest()
@ -121,370 +76,9 @@ during the `register()` call.
You do not need to map all the fields of the model, any fields not mapped will fall back on their ``verbose_name``. Django provides a default ``verbose_name`` which is a "munged camel case version" so ``product_name`` would become ``Product Name`` by default.
**Masking fields**
Fields that contain sensitive info and we want keep track of field change but not to contain the exact change.
To mask specific fields from the log you can pass ``mask_fields`` to the ``register``
method. If ``mask_fields`` is specified, the first half value of the fields is masked using ``*``.
For example, to mask the field ``address``, use::
auditlog.register(MyModel, mask_fields=['address'])
You can also specify a custom masking function by passing ``mask_callable`` to the ``register``
method. The ``mask_callable`` should be a dotted path to a function that takes a string and returns
a masked version of that string.
For example, to use a custom masking function::
# In your_app/utils.py
def custom_mask(value: str) -> str:
return "****" + value[-4:] # Only show last 4 characters
# In your models.py
auditlog.register(
MyModel,
mask_fields=['credit_card'],
mask_callable='your_app.utils.custom_mask'
)
Additionally, you can set a global default masking function that will be used when a model-specific
mask_callable is not provided. To do this, add the following to your Django settings::
AUDITLOG_MASK_CALLABLE = 'your_app.utils.custom_mask'
The masking function priority is as follows:
1. Model-specific ``mask_callable`` if provided in ``register()``
2. ``AUDITLOG_MASK_CALLABLE`` from settings if configured
3. Default ``mask_str`` function which masks the first half of the string with asterisks
If ``mask_callable`` is not specified and no global default is configured, the default masking function will be used which masks
the first half of the string with asterisks.
.. versionadded:: 2.0.0
Masking fields
**Many-to-many fields**
Changes to many-to-many fields are not tracked by default. If you want to enable tracking of a many-to-many field on a model, pass ``m2m_fields`` to the ``register`` method:
.. code-block:: python
auditlog.register(MyModel, m2m_fields={"tags", "contacts"})
This functionality is based on the ``m2m_changed`` signal sent by the ``through`` model of the relationship.
Note that when the user changes multiple many-to-many fields on the same object through the admin, both adding and removing some objects from each, this code will generate multiple log entries: each log entry will represent a single operation (add or delete) of a single field, e.g. if you both add and delete values from 2 fields on the same form in the same request, you'll get 4 log entries.
.. versionadded:: 2.1.0
**Serialized Data**
The state of an object following a change action may be optionally serialized and persisted in the ``LogEntry.serialized_data`` JSONField. To enable this feature for a registered model, add ``serialize_data=True`` to the kwargs on the ``auditlog.register(...)`` method. Object serialization will not occur unless this kwarg is set.
.. code-block:: python
auditlog.register(MyModel, serialize_data=True)
Objects are serialized using the Django core serializer. Keyword arguments may be passed to the serializer through ``serialize_kwargs``.
.. code-block:: python
auditlog.register(
MyModel,
serialize_data=True,
serialize_kwargs={"fields": ["foo", "bar", "biz", "baz"]}
)
Note that all fields on the object will be serialized unless restricted with one or more configurations. The `serialize_kwargs` option contains a `fields` argument and this may be given an inclusive list of field names to serialize (as shown above). Alternatively, one may set ``serialize_auditlog_fields_only`` to ``True`` when registering a model with ``exclude_fields`` and ``include_fields`` set (as shown below). This will cause the data persisted in ``LogEntry.serialized_data`` to be limited to the same scope that is persisted within the ``LogEntry.changes`` field.
.. code-block:: python
auditlog.register(
MyModel,
exclude_fields=["ssn", "confidential"]
serialize_data=True,
serialize_auditlog_fields_only=True
)
Field masking is supported in object serialization. Any value belonging to a field whose name is found in the ``mask_fields`` list will be masked in the serialized object data. Masked values are obfuscated with asterisks in the same way as they are in the ``LogEntry.changes`` field.
Correlation ID
--------------
You can store a correlation ID (cid) in the log entries by:
1. Reading from a request header (specified by `AUDITLOG_CID_HEADER`)
2. Using a custom cid getter (specified by `AUDITLOG_CID_GETTER`)
Using the custom getter is helpful for integrating with a third-party cid package
such as `django-cid <https://pypi.org/project/django-cid/>`_.
Settings
--------
**AUDITLOG_INCLUDE_ALL_MODELS**
You can use this setting to register all your models:
.. code-block:: python
AUDITLOG_INCLUDE_ALL_MODELS=True
.. versionadded:: 2.1.0
**AUDITLOG_EXCLUDE_TRACKING_FIELDS**
You can use this setting to exclude named fields from ALL models.
This is useful when lots of models share similar fields like
```created``` and ```modified``` and you want those excluded from
logging.
It will be considered when ``AUDITLOG_INCLUDE_ALL_MODELS`` is `True`.
.. code-block:: python
AUDITLOG_EXCLUDE_TRACKING_FIELDS = (
"created",
"modified"
)
.. versionadded:: 3.0.0
**AUDITLOG_DISABLE_REMOTE_ADDR**
When using "AuditlogMiddleware",
the IP address is logged by default, you can use this setting
to exclude the IP address from logging.
It will be considered when ``AUDITLOG_DISABLE_REMOTE_ADDR`` is `True`.
.. code-block:: python
AUDITLOG_DISABLE_REMOTE_ADDR = True
.. versionadded:: 3.0.0
**AUDITLOG_MASK_TRACKING_FIELDS**
You can use this setting to mask specific field values in all tracked models
while still logging changes. This is useful when models contain sensitive fields
like `password`, `api_key`, or `secret_token` that should not be logged
in plain text but need to be auditable.
When a masked field changes, its value will be replaced with a masked
representation (e.g., `****`) in the audit log instead of storing the actual value.
This setting will be applied only when ``AUDITLOG_INCLUDE_ALL_MODELS`` is `True`.
.. code-block:: python
AUDITLOG_MASK_TRACKING_FIELDS = (
"password",
"api_key",
"secret_token"
)
.. versionadded:: 3.1.0
**AUDITLOG_EXCLUDE_TRACKING_MODELS**
You can use this setting to exclude models in registration process.
It will be considered when ``AUDITLOG_INCLUDE_ALL_MODELS`` is `True`.
.. code-block:: python
AUDITLOG_EXCLUDE_TRACKING_MODELS = (
"<app_name>",
"<app_name>.<model>"
)
.. versionadded:: 2.1.0
**AUDITLOG_INCLUDE_TRACKING_MODELS**
You can use this setting to configure your models registration and other behaviours.
It must be a list or tuple. Each item in this setting can be a:
* ``str``: To register a model.
* ``dict``: To register a model and define its logging behaviour. e.g. include_fields, exclude_fields.
.. code-block:: python
AUDITLOG_INCLUDE_TRACKING_MODELS = (
"<appname>.<model1>",
{
"model": "<appname>.<model2>",
"include_fields": ["field1", "field2"],
"exclude_fields": ["field3", "field4"],
"mapping_fields": {
"field1": "FIELD",
},
"mask_fields": ["field5", "field6"],
"m2m_fields": ["field7", "field8"],
"serialize_data": True,
"serialize_auditlog_fields_only": False,
"serialize_kwargs": {"fields": ["foo", "bar", "biz", "baz"]},
},
"<appname>.<model3>",
)
.. versionadded:: 2.1.0
**AUDITLOG_DISABLE_ON_RAW_SAVE**
Disables logging during raw save. (I.e. for instance using loaddata)
.. note::
M2M operations will still be logged, since they're never considered `raw`. To disable them
you must remove their setting or use the `disable_auditlog` context manager.
.. versionadded:: 2.2.0
**AUDITLOG_CID_HEADER**
The request header containing the Correlation ID value to use in all log entries created as a result of the request.
The value can of in the format `HTTP_MY_HEADER` or `my-header`.
.. versionadded:: 3.0.0
**AUDITLOG_CID_GETTER**
The function to use to retrieve the Correlation ID. The value can be a callable or a string import path.
If the value is `None`, the default getter will be used.
.. versionadded:: 3.0.0
**AUDITLOG_CHANGE_DISPLAY_TRUNCATE_LENGTH**
This configuration variable defines the truncation behavior for strings in `changes_display_dict`, with a default value of `140` characters.
0: The entire string is truncated, resulting in an empty output.
Positive values (e.g., 5): Truncates the string, keeping only the specified number of characters followed by an ellipsis (...) after the limit.
Negative values: No truncation occurs, and the full string is displayed.
.. versionadded:: 3.1.0
**AUDITLOG_STORE_JSON_CHANGES**
This configuration variable defines whether to store changes as JSON.
This means that primitives such as booleans, integers, etc. will be represented using their JSON equivalents. For example, instead of storing
`None` as a string, it will be stored as a JSON `null` in the `changes` field. Same goes for other primitives.
.. 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
------
Middleware
**********
When using automatic logging, the actor is empty by default. However, auditlog can set the actor from the current
request automatically. This does not need any custom code, adding a middleware class is enough. When an actor is logged
the remote address of that actor will be logged as well.
@ -506,43 +100,6 @@ It is recommended to keep all middleware that alters the request loaded before A
user as actor. To only have some object changes to be logged with the current request's user as actor manual logging is
required.
Context managers
----------------
Set actor
*********
To enable the automatic logging of the actors outside of request context (e.g. in a Celery task), you can use a context
manager::
from auditlog.context import set_actor
def do_stuff(actor_id: int):
actor = get_user(actor_id)
with set_actor(actor):
# if your code here leads to creation of LogEntry instances, these will have the actor set
...
.. versionadded:: 2.1.0
Disable auditlog
****************
Disable auditlog temporary, for instance if you need to install a large fixture on a live system or cleanup
corrupt data::
from auditlog.context import disable_auditlog
with disable_auditlog():
# Do things silently here
...
.. versionadded:: 2.2.0
Object history
--------------
@ -550,10 +107,9 @@ Auditlog ships with a custom field that enables you to easily get the log entrie
functionality is built on Django's content types framework (:py:mod:`django.contrib.contenttypes`). Using this field in
your models is equally easy as any other field::
from django.db import models
from auditlog.models import AuditlogHistoryField
from auditlog.registry import auditlog
from django.db import models
class MyModel(models.Model):
history = AuditlogHistoryField()
@ -606,9 +162,10 @@ Many-to-many relationships
.. versionadded:: 0.3.0
.. note::
.. warning::
This section shows a workaround which can be used to track many-to-many relationships on older versions of django-auditlog. For versions 2.1.0 and onwards, please see the many-to-many fields section of :ref:`Automatically logging changes`.
To-many relations are not officially supported. However, this section shows a workaround which can be used for now.
In the future, this workaround may be used in an official API or a completly different strategy might be chosen.
**Do not rely on the workaround here to be stable across releases.**
By default, many-to-many relationships are not tracked by Auditlog.
@ -634,17 +191,12 @@ Management commands
Auditlog provides the ``auditlogflush`` management command to clear all log entries from the database.
By default, the command asks for confirmation. It is possible to run the command with the ``-y`` or ``--yes`` flag to skip
By default, the command asks for confirmation. It is possible to run the command with the `-y` or `--yes` flag to skip
confirmation and immediately delete all entries.
You may also specify a date using the ``-b`` or ``--before-date`` option in ISO 8601 format (YYYY-mm-dd) to delete all
log entries prior to a given date. This may be used to implement time based retention windows.
.. versionadded:: 2.1.0
.. warning::
Using the ``auditlogflush`` command deletes log entries permanently and irreversibly from the database.
Using the ``auditlogflush`` command deletes all log entries permanently and irreversibly from the database.
Django Admin integration
------------------------
@ -653,26 +205,3 @@ Django Admin integration
When ``auditlog`` is added to your ``INSTALLED_APPS`` setting a customized admin class is active providing an enhanced
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)

View file

@ -1,5 +1,6 @@
[tool.black]
target-version = ["py39"]
target-version = ["py36"]
[tool.isort]
profile = "black"
known_first_party = "auditlog"

Some files were not shown because too many files have changed in this diff Show more