mirror of
https://github.com/jazzband/django-auditlog.git
synced 2026-03-16 22:20:26 +00:00
Compare commits
98 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e3a2ec1a7 | ||
|
|
dfd5b79d2d | ||
|
|
4154560de3 | ||
|
|
3f255a02d9 | ||
|
|
6d170da5fc | ||
|
|
198c060c3b | ||
|
|
ede4d10164 | ||
|
|
aedb6ead39 | ||
|
|
fb762a054f | ||
|
|
dc636716b0 | ||
|
|
66125030a8 | ||
|
|
eb9eefd76f | ||
|
|
d02ed6b9e0 | ||
|
|
074e6aa145 | ||
|
|
03336f9ef2 | ||
|
|
7d13fd4ba8 | ||
|
|
0e58a9d2d5 | ||
|
|
8c9b7b4a6e | ||
|
|
b1b6f9f4dd | ||
|
|
d417f30142 | ||
|
|
bd03eb6199 | ||
|
|
74ba152a67 | ||
|
|
85056b73c3 | ||
|
|
d87b92923e | ||
|
|
3051d230b9 | ||
|
|
65ebec6663 | ||
|
|
4de16fbd40 | ||
|
|
572aeebec7 | ||
|
|
5379e1e5d0 | ||
|
|
a7c07a491d | ||
|
|
8003b069c9 | ||
|
|
9ef8cf2476 | ||
|
|
d4d9f287a6 | ||
|
|
0eff3e8404 | ||
|
|
ca5f0aedfd | ||
|
|
138e4fc948 | ||
|
|
8fe73932a0 | ||
|
|
6184ec8adb | ||
|
|
637e04c31e | ||
|
|
af78976e53 | ||
|
|
3a58e0a999 | ||
|
|
b640df67a3 | ||
|
|
ecdefde9ed | ||
|
|
3f7f005377 | ||
|
|
d1e7566668 | ||
|
|
df185e32db | ||
|
|
cbcaff3569 | ||
|
|
6d7e8c7968 | ||
|
|
6414b7aedb | ||
|
|
4c1d573981 | ||
|
|
939dd9b298 | ||
|
|
de650b09c7 | ||
|
|
856ee0ae04 | ||
|
|
c4907bcd52 | ||
|
|
fb3fac5cce | ||
|
|
602c760b4c | ||
|
|
3e540bff6f | ||
|
|
a27c045280 | ||
|
|
6e51997728 | ||
|
|
925f0dbaee | ||
|
|
8150d35aef | ||
|
|
5621777622 | ||
|
|
d4f99c2729 | ||
|
|
938e644177 | ||
|
|
b1ecc8f754 | ||
|
|
512cd28318 | ||
|
|
0634357119 | ||
|
|
a53a6facfe | ||
|
|
4c3ee0b36d | ||
|
|
22dcbc4d06 | ||
|
|
07a3a83828 | ||
|
|
2958f58acd | ||
|
|
5759020015 | ||
|
|
5bb701d821 | ||
|
|
2c0bd0fac6 | ||
|
|
92805e2084 | ||
|
|
d412b2b16a | ||
|
|
8af8011073 | ||
|
|
ba2c2e32be | ||
|
|
a652eebfaf | ||
|
|
1569051f48 | ||
|
|
b768dc74f6 | ||
|
|
ceae2f9b4a | ||
|
|
7b035943c7 | ||
|
|
a93d9ef665 | ||
|
|
2f949dde25 | ||
|
|
a0ae594425 | ||
|
|
5e2daa4c4c | ||
|
|
b2aff7e313 | ||
|
|
2b03d25343 | ||
|
|
5289482548 | ||
|
|
156d655852 | ||
|
|
ac720cd30e | ||
|
|
e6fc81016c | ||
|
|
d78f813968 | ||
|
|
b9bf01b31d | ||
|
|
f3238c9bdb | ||
|
|
447347710d |
75 changed files with 3910 additions and 459 deletions
30
.github/actions/setup-python-deps/action.yml
vendored
Normal file
30
.github/actions/setup-python-deps/action.yml
vendored
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
name: 'Setup Python and Dependencies'
|
||||||
|
description: 'Common setup steps for Python and pip dependencies'
|
||||||
|
|
||||||
|
inputs:
|
||||||
|
python-version:
|
||||||
|
description: 'Python version to setup'
|
||||||
|
required: true
|
||||||
|
cache-key-prefix:
|
||||||
|
description: 'Prefix for pip cache key'
|
||||||
|
required: true
|
||||||
|
|
||||||
|
runs:
|
||||||
|
using: 'composite'
|
||||||
|
steps:
|
||||||
|
- name: Set up Python ${{ inputs.python-version }}
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: ${{ inputs.python-version }}
|
||||||
|
|
||||||
|
- name: Cache pip
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/.cache/pip
|
||||||
|
key: ${{ inputs.cache-key-prefix }}-${{ inputs.python-version }}-${{ hashFiles('**/pyproject.toml') }}
|
||||||
|
|
||||||
|
- name: Install Python dependencies
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
python -m pip install --upgrade tox tox-gh-actions
|
||||||
13
.github/dependabot.yml
vendored
Normal file
13
.github/dependabot.yml
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
# 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
|
||||||
10
.github/workflows/release.yml
vendored
10
.github/workflows/release.yml
vendored
|
|
@ -11,14 +11,14 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v4
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: '3.8'
|
python-version: '3.10'
|
||||||
|
|
||||||
- name: Get pip cache dir
|
- name: Get pip cache dir
|
||||||
id: pip-cache
|
id: pip-cache
|
||||||
|
|
@ -26,7 +26,7 @@ jobs:
|
||||||
echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT
|
echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Cache
|
- name: Cache
|
||||||
uses: actions/cache@v3
|
uses: actions/cache@v5
|
||||||
with:
|
with:
|
||||||
path: ${{ steps.pip-cache.outputs.dir }}
|
path: ${{ steps.pip-cache.outputs.dir }}
|
||||||
key: release-${{ hashFiles('**/setup.py') }}
|
key: release-${{ hashFiles('**/setup.py') }}
|
||||||
|
|
@ -36,7 +36,7 @@ jobs:
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
python -m pip install -U pip
|
python -m pip install -U pip
|
||||||
python -m pip install -U setuptools twine wheel
|
python -m pip install -U setuptools==75.6.0 twine==6.0.1 wheel pkginfo
|
||||||
|
|
||||||
- name: Build package
|
- name: Build package
|
||||||
run: |
|
run: |
|
||||||
|
|
|
||||||
142
.github/workflows/test.yml
vendored
142
.github/workflows/test.yml
vendored
|
|
@ -3,67 +3,125 @@ name: Test
|
||||||
on: [push, pull_request]
|
on: [push, pull_request]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
test-sqlite:
|
||||||
|
name: SQLite • Python ${{ matrix.python-version }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
max-parallel: 5
|
|
||||||
matrix:
|
matrix:
|
||||||
python-version: ['3.8', '3.9', '3.10', '3.11', '3.12']
|
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Setup Python and dependencies
|
||||||
|
uses: ./.github/actions/setup-python-deps
|
||||||
|
with:
|
||||||
|
python-version: ${{ matrix.python-version }}
|
||||||
|
cache-key-prefix: sqlite3
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
env:
|
||||||
|
TEST_DB_BACKEND: sqlite3
|
||||||
|
run: tox -v
|
||||||
|
|
||||||
|
- name: Upload coverage
|
||||||
|
uses: codecov/codecov-action@v5
|
||||||
|
with:
|
||||||
|
name: SQLite • Python ${{ matrix.python-version }}
|
||||||
|
|
||||||
|
test-postgres:
|
||||||
|
name: PostgreSQL • Python ${{ matrix.python-version }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:13
|
image: postgres:15
|
||||||
env:
|
env:
|
||||||
POSTGRES_USER: postgres
|
POSTGRES_USER: postgres
|
||||||
POSTGRES_PASSWORD: postgres
|
POSTGRES_PASSWORD: postgres
|
||||||
POSTGRES_DB: postgres
|
POSTGRES_DB: auditlog
|
||||||
ports:
|
ports:
|
||||||
- 5432/tcp
|
- 5432/tcp
|
||||||
options: >-
|
options: >-
|
||||||
--health-cmd pg_isready
|
--health-cmd pg_isready
|
||||||
--health-interval 10s
|
--health-interval 10s
|
||||||
--health-timeout 5s
|
--health-timeout 5s
|
||||||
--health-retries 5
|
--health-retries 10
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Setup Python and dependencies
|
||||||
uses: actions/setup-python@v3
|
uses: ./.github/actions/setup-python-deps
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
|
cache-key-prefix: postgresql
|
||||||
|
|
||||||
- name: Get pip cache dir
|
- name: Run tests
|
||||||
id: pip-cache
|
env:
|
||||||
run: |
|
TEST_DB_BACKEND: postgresql
|
||||||
echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT
|
TEST_DB_HOST: localhost
|
||||||
|
TEST_DB_USER: postgres
|
||||||
|
TEST_DB_PASS: postgres
|
||||||
|
TEST_DB_NAME: auditlog
|
||||||
|
TEST_DB_PORT: ${{ job.services.postgres.ports[5432] }}
|
||||||
|
|
||||||
- name: Cache
|
run: tox -v
|
||||||
uses: actions/cache@v3
|
|
||||||
with:
|
|
||||||
path: ${{ steps.pip-cache.outputs.dir }}
|
|
||||||
key:
|
|
||||||
-${{ matrix.python-version }}-v1-${{ hashFiles('**/setup.py') }}
|
|
||||||
restore-keys: |
|
|
||||||
-${{ matrix.python-version }}-v1-
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Upload coverage
|
||||||
run: |
|
uses: codecov/codecov-action@v5
|
||||||
python -m pip install --upgrade pip
|
with:
|
||||||
python -m pip install --upgrade tox tox-gh-actions
|
name: PostgreSQL • Python ${{ matrix.python-version }}
|
||||||
|
|
||||||
- name: Tox tests
|
test-mysql:
|
||||||
run: |
|
name: MySQL • Python ${{ matrix.python-version }}
|
||||||
tox -v
|
runs-on: ubuntu-latest
|
||||||
env:
|
strategy:
|
||||||
TEST_DB_HOST: localhost
|
fail-fast: false
|
||||||
TEST_DB_USER: postgres
|
matrix:
|
||||||
TEST_DB_PASS: postgres
|
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
||||||
TEST_DB_NAME: postgres
|
services:
|
||||||
TEST_DB_PORT: ${{ job.services.postgres.ports[5432] }}
|
mysql:
|
||||||
|
image: mysql:8.4
|
||||||
|
env:
|
||||||
|
MYSQL_DATABASE: auditlog
|
||||||
|
MYSQL_USER: mysql
|
||||||
|
MYSQL_PASSWORD: mysql
|
||||||
|
MYSQL_ROOT_PASSWORD: mysql
|
||||||
|
ports:
|
||||||
|
- 3306/tcp
|
||||||
|
options: >-
|
||||||
|
--health-cmd="sh -c 'export MYSQL_PWD=\"$MYSQL_ROOT_PASSWORD\"; mysqladmin ping -h 127.0.0.1 --protocol=TCP -uroot --silent || exit 1'"
|
||||||
|
--health-interval=10s
|
||||||
|
--health-timeout=5s
|
||||||
|
--health-retries=20
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Upload coverage
|
- name: Install MySQL client libraries
|
||||||
uses: codecov/codecov-action@v3
|
run: |
|
||||||
with:
|
sudo apt-get update
|
||||||
name: Python ${{ matrix.python-version }}
|
sudo apt-get install -y libmysqlclient-dev pkg-config mysql-client
|
||||||
|
|
||||||
|
- name: Setup Python and dependencies
|
||||||
|
uses: ./.github/actions/setup-python-deps
|
||||||
|
with:
|
||||||
|
python-version: ${{ matrix.python-version }}
|
||||||
|
cache-key-prefix: mysql
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
env:
|
||||||
|
TEST_DB_BACKEND: mysql
|
||||||
|
TEST_DB_HOST: 127.0.0.1
|
||||||
|
TEST_DB_USER: root
|
||||||
|
TEST_DB_PASS: mysql
|
||||||
|
TEST_DB_NAME: auditlog
|
||||||
|
TEST_DB_PORT: ${{ job.services.mysql.ports[3306] }}
|
||||||
|
run: tox -v
|
||||||
|
|
||||||
|
- name: Upload coverage
|
||||||
|
uses: codecov/codecov-action@v5
|
||||||
|
with:
|
||||||
|
name: MySQL • Python ${{ matrix.python-version }}
|
||||||
|
|
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -48,7 +48,6 @@ coverage.xml
|
||||||
cover/
|
cover/
|
||||||
|
|
||||||
# Translations
|
# Translations
|
||||||
*.mo
|
|
||||||
*.pot
|
*.pot
|
||||||
|
|
||||||
# Django stuff:
|
# Django stuff:
|
||||||
|
|
@ -80,3 +79,4 @@ venv.bak/
|
||||||
|
|
||||||
### JetBrains
|
### JetBrains
|
||||||
.idea/
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,29 @@
|
||||||
---
|
---
|
||||||
repos:
|
repos:
|
||||||
- repo: https://github.com/psf/black
|
- repo: https://github.com/psf/black-pre-commit-mirror
|
||||||
rev: 23.12.1
|
rev: 26.3.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: black
|
- id: black
|
||||||
language_version: python3.8
|
language_version: python3.10
|
||||||
args:
|
args:
|
||||||
- "--target-version"
|
- "--target-version"
|
||||||
- "py38"
|
- "py310"
|
||||||
- repo: https://github.com/PyCQA/flake8
|
- repo: https://github.com/PyCQA/flake8
|
||||||
rev: "6.1.0"
|
rev: "7.3.0"
|
||||||
hooks:
|
hooks:
|
||||||
- id: flake8
|
- id: flake8
|
||||||
args: ["--max-line-length", "110"]
|
args: ["--max-line-length", "110"]
|
||||||
- repo: https://github.com/PyCQA/isort
|
- repo: https://github.com/PyCQA/isort
|
||||||
rev: 5.13.2
|
rev: 8.0.1
|
||||||
hooks:
|
hooks:
|
||||||
- id: isort
|
- id: isort
|
||||||
- repo: https://github.com/asottile/pyupgrade
|
- repo: https://github.com/asottile/pyupgrade
|
||||||
rev: v3.15.0
|
rev: v3.21.2
|
||||||
hooks:
|
hooks:
|
||||||
- id: pyupgrade
|
- id: pyupgrade
|
||||||
args: [--py38-plus]
|
args: [--py310-plus]
|
||||||
- repo: https://github.com/adamchainz/django-upgrade
|
- repo: https://github.com/adamchainz/django-upgrade
|
||||||
rev: 1.15.0
|
rev: 1.30.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: django-upgrade
|
- id: django-upgrade
|
||||||
args: [--target-version, "3.2"]
|
args: [--target-version, "4.2"]
|
||||||
|
|
|
||||||
95
CHANGELOG.md
95
CHANGELOG.md
|
|
@ -2,9 +2,104 @@
|
||||||
|
|
||||||
## Next Release
|
## Next Release
|
||||||
|
|
||||||
|
#### Fixes
|
||||||
|
|
||||||
|
- `KeyError` when calling `changes_str` on a log entry that tracks many-to-many field changes ([#798](https://github.com/jazzband/django-auditlog/pull/798))
|
||||||
|
|
||||||
|
## 3.4.1 (2025-12-13)
|
||||||
|
|
||||||
|
#### Fixes
|
||||||
|
|
||||||
|
- Fix AttributeError when AUDITLOG_LOGENTRY_MODEL is not set ([#789](https://github.com/jazzband/django-auditlog/pull/789))
|
||||||
|
|
||||||
|
## 3.4.0 (2025-12-04)
|
||||||
|
|
||||||
|
#### Improvements
|
||||||
|
|
||||||
|
- feat: Add CustomLogEntry model support and update tests ([#764)](https://github.com/jazzband/django-auditlog/pull/764))
|
||||||
|
- feat: Add `AUDITLOG_USE_FK_STRING_REPRESENTATION` setting that controls how foreign key changes are represented ([#779)](https://github.com/jazzband/django-auditlog/pull/779))
|
||||||
|
- Add `AUDITLOG_USE_BASE_MANAGER` setting to override default manager use ([#766](https://github.com/jazzband/django-auditlog/pull/766))
|
||||||
|
- Drop 'Python 3.9' support ([#773](https://github.com/jazzband/django-auditlog/pull/773))
|
||||||
|
|
||||||
|
#### Fixes
|
||||||
|
|
||||||
|
- Make diffing more robust for polymorphic models ([#784](https://github.com/jazzband/django-auditlog/pull/784))
|
||||||
|
- Amend setup configuration to include non-python package files ([#769](https://github.com/jazzband/django-auditlog/pull/769))
|
||||||
|
|
||||||
|
## 3.3.0 (2025-09-18)
|
||||||
|
|
||||||
|
#### Improvements
|
||||||
|
|
||||||
|
- CI: Extend CI and local test coverage to MySQL and SQLite ([#744](https://github.com/jazzband/django-auditlog/pull/744))
|
||||||
|
- feat: Add audit log history view to Django Admin. ([#743](https://github.com/jazzband/django-auditlog/pull/743))
|
||||||
|
- Fix Expression test compatibility for Django 6.0+ ([#759](https://github.com/jazzband/django-auditlog/pull/759))
|
||||||
|
- Add I18N Support ([#762](https://github.com/jazzband/django-auditlog/pull/762))
|
||||||
|
|
||||||
|
## 3.2.1 (2025-07-03)
|
||||||
|
|
||||||
|
#### 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)
|
## 3.0.0-beta.4 (2024-01-02)
|
||||||
|
|
||||||
#### Improvements
|
#### 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))
|
- 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: 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))
|
- Django: Drop Django 4.1 support ([#598](https://github.com/jazzband/django-auditlog/pull/598))
|
||||||
|
|
|
||||||
3
MANIFEST.in
Normal file
3
MANIFEST.in
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
recursive-include auditlog/templates *
|
||||||
|
recursive-include auditlog/static *
|
||||||
|
recursive-include auditlog/locale *
|
||||||
42
Makefile
Normal file
42
Makefile
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
# Django Auditlog Makefile
|
||||||
|
|
||||||
|
# Default target shows help
|
||||||
|
.DEFAULT_GOAL := help
|
||||||
|
.PHONY: help install test makemessages compilemessages create-locale i18n clean
|
||||||
|
|
||||||
|
# Variables
|
||||||
|
AUDITLOG_DIR := auditlog
|
||||||
|
|
||||||
|
install: ## Install dependencies
|
||||||
|
pip install -e .
|
||||||
|
|
||||||
|
test: ## Run tests
|
||||||
|
./runtests.sh
|
||||||
|
|
||||||
|
makemessages: ## Extract translatable strings and create/update .po files for all languages
|
||||||
|
cd $(AUDITLOG_DIR) && \
|
||||||
|
django-admin makemessages --add-location=file -a --ignore=__pycache__ --ignore=migrations
|
||||||
|
|
||||||
|
compilemessages: ## Compile all translation files (.po to .mo)
|
||||||
|
cd $(AUDITLOG_DIR) && \
|
||||||
|
django-admin compilemessages
|
||||||
|
|
||||||
|
create-locale: ## Create initial locale structure for a new language (requires LANG=<code>)
|
||||||
|
@if [ -z "$(LANG)" ]; then \
|
||||||
|
echo "Error: LANG parameter is required. Usage: make create-locale LANG=<language_code>"; \
|
||||||
|
echo "Examples: make create-locale LANG=ko, make create-locale LANG=ja"; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
mkdir -p $(AUDITLOG_DIR)/locale/$(LANG)/LC_MESSAGES
|
||||||
|
cd $(AUDITLOG_DIR) && \
|
||||||
|
django-admin makemessages --add-location=file -l $(LANG) --ignore=__pycache__ --ignore=migrations
|
||||||
|
|
||||||
|
i18n: makemessages compilemessages ## Full i18n workflow: extract strings, compile messages
|
||||||
|
|
||||||
|
clean: ## Clean compiled translation files (.mo files)
|
||||||
|
find $(AUDITLOG_DIR)/locale -name "*.mo" -delete
|
||||||
|
|
||||||
|
help: ## Help message for targets
|
||||||
|
@echo "Available commands:"
|
||||||
|
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
|
||||||
|
| awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
|
||||||
|
|
@ -8,6 +8,10 @@ django-auditlog
|
||||||
[](https://pypi.python.org/pypi/django-auditlog)
|
[](https://pypi.python.org/pypi/django-auditlog)
|
||||||
[](https://pypi.python.org/pypi/django-auditlog)
|
[](https://pypi.python.org/pypi/django-auditlog)
|
||||||
|
|
||||||
|
**Migrate to V3**
|
||||||
|
|
||||||
|
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 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.
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,24 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
from importlib.metadata import version
|
from importlib.metadata import version
|
||||||
|
|
||||||
|
from django.apps import apps as django_apps
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.exceptions import ImproperlyConfigured
|
||||||
|
|
||||||
__version__ = version("django-auditlog")
|
__version__ = version("django-auditlog")
|
||||||
|
|
||||||
|
|
||||||
|
def get_logentry_model():
|
||||||
|
model_string = getattr(settings, "AUDITLOG_LOGENTRY_MODEL", "auditlog.LogEntry")
|
||||||
|
try:
|
||||||
|
return django_apps.get_model(model_string, require_ready=False)
|
||||||
|
except ValueError:
|
||||||
|
raise ImproperlyConfigured(
|
||||||
|
"AUDITLOG_LOGENTRY_MODEL must be of the form 'app_label.model_name'"
|
||||||
|
)
|
||||||
|
except LookupError:
|
||||||
|
raise ImproperlyConfigured(
|
||||||
|
"AUDITLOG_LOGENTRY_MODEL refers to model '%s' that has not been installed"
|
||||||
|
% model_string
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,16 @@ from django.contrib import admin
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from auditlog import get_logentry_model
|
||||||
from auditlog.filters import CIDFilter, ResourceTypeFilter
|
from auditlog.filters import CIDFilter, ResourceTypeFilter
|
||||||
from auditlog.mixins import LogEntryAdminMixin
|
from auditlog.mixins import LogEntryAdminMixin
|
||||||
from auditlog.models import LogEntry
|
|
||||||
|
LogEntry = get_logentry_model()
|
||||||
|
|
||||||
|
|
||||||
@admin.register(LogEntry)
|
@admin.register(LogEntry)
|
||||||
class LogEntryAdmin(admin.ModelAdmin, LogEntryAdminMixin):
|
class LogEntryAdmin(admin.ModelAdmin, LogEntryAdminMixin):
|
||||||
|
date_hierarchy = "timestamp"
|
||||||
list_select_related = ["content_type", "actor"]
|
list_select_related = ["content_type", "actor"]
|
||||||
list_display = [
|
list_display = [
|
||||||
"created",
|
"created",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
from contextvars import ContextVar
|
from contextvars import ContextVar
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
|
|
@ -8,7 +7,7 @@ from django.utils.module_loading import import_string
|
||||||
correlation_id = ContextVar("auditlog_correlation_id", default=None)
|
correlation_id = ContextVar("auditlog_correlation_id", default=None)
|
||||||
|
|
||||||
|
|
||||||
def set_cid(request: Optional[HttpRequest] = None) -> None:
|
def set_cid(request: HttpRequest | None = None) -> None:
|
||||||
"""
|
"""
|
||||||
A function to read the cid from a request.
|
A function to read the cid from a request.
|
||||||
If the header is not in the request, then we set it to `None`.
|
If the header is not in the request, then we set it to `None`.
|
||||||
|
|
@ -40,11 +39,11 @@ def set_cid(request: Optional[HttpRequest] = None) -> None:
|
||||||
correlation_id.set(cid)
|
correlation_id.set(cid)
|
||||||
|
|
||||||
|
|
||||||
def _get_cid() -> Optional[str]:
|
def _get_cid() -> str | None:
|
||||||
return correlation_id.get()
|
return correlation_id.get()
|
||||||
|
|
||||||
|
|
||||||
def get_cid() -> Optional[str]:
|
def get_cid() -> str | None:
|
||||||
"""
|
"""
|
||||||
Calls the cid getter function based on `settings.AUDITLOG_CID_GETTER`
|
Calls the cid getter function based on `settings.AUDITLOG_CID_GETTER`
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,11 @@ settings.AUDITLOG_EXCLUDE_TRACKING_FIELDS = getattr(
|
||||||
settings, "AUDITLOG_EXCLUDE_TRACKING_FIELDS", ()
|
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
|
# Disable on raw save to avoid logging imports and similar
|
||||||
settings.AUDITLOG_DISABLE_ON_RAW_SAVE = getattr(
|
settings.AUDITLOG_DISABLE_ON_RAW_SAVE = getattr(
|
||||||
settings, "AUDITLOG_DISABLE_ON_RAW_SAVE", False
|
settings, "AUDITLOG_DISABLE_ON_RAW_SAVE", False
|
||||||
|
|
@ -40,3 +45,34 @@ settings.AUDITLOG_TWO_STEP_MIGRATION = getattr(
|
||||||
settings.AUDITLOG_USE_TEXT_CHANGES_IF_JSON_IS_NOT_PRESENT = getattr(
|
settings.AUDITLOG_USE_TEXT_CHANGES_IF_JSON_IS_NOT_PRESENT = getattr(
|
||||||
settings, "AUDITLOG_USE_TEXT_CHANGES_IF_JSON_IS_NOT_PRESENT", False
|
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
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -6,26 +6,41 @@ from functools import partial
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.db.models.signals import pre_save
|
from django.db.models.signals import pre_save
|
||||||
|
|
||||||
from auditlog.models import LogEntry
|
from auditlog import get_logentry_model
|
||||||
|
|
||||||
auditlog_value = ContextVar("auditlog_value")
|
auditlog_value = ContextVar("auditlog_value")
|
||||||
auditlog_disabled = ContextVar("auditlog_disabled", default=False)
|
auditlog_disabled = ContextVar("auditlog_disabled", default=False)
|
||||||
|
|
||||||
|
|
||||||
@contextlib.contextmanager
|
@contextlib.contextmanager
|
||||||
def set_actor(actor, remote_addr=None):
|
def set_actor(actor, remote_addr=None, remote_port=None):
|
||||||
"""Connect a signal receiver with current user attached."""
|
|
||||||
# Initialize thread local storage
|
|
||||||
context_data = {
|
context_data = {
|
||||||
"signal_duid": ("set_actor", time.time()),
|
"actor": actor,
|
||||||
"remote_addr": remote_addr,
|
"remote_addr": remote_addr,
|
||||||
|
"remote_port": remote_port,
|
||||||
}
|
}
|
||||||
|
return call_context_manager(context_data)
|
||||||
|
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def set_extra_data(context_data):
|
||||||
|
return call_context_manager(context_data)
|
||||||
|
|
||||||
|
|
||||||
|
def call_context_manager(context_data):
|
||||||
|
"""Connect a signal receiver with current user attached."""
|
||||||
|
LogEntry = get_logentry_model()
|
||||||
|
# Initialize thread local storage
|
||||||
|
context_data["signal_duid"] = ("set_actor", time.time())
|
||||||
auditlog_value.set(context_data)
|
auditlog_value.set(context_data)
|
||||||
|
|
||||||
# Connect signal for automatic logging
|
# Connect signal for automatic logging
|
||||||
set_actor = partial(_set_actor, user=actor, signal_duid=context_data["signal_duid"])
|
set_extra_data = partial(
|
||||||
|
_set_extra_data,
|
||||||
|
signal_duid=context_data["signal_duid"],
|
||||||
|
)
|
||||||
pre_save.connect(
|
pre_save.connect(
|
||||||
set_actor,
|
set_extra_data,
|
||||||
sender=LogEntry,
|
sender=LogEntry,
|
||||||
dispatch_uid=context_data["signal_duid"],
|
dispatch_uid=context_data["signal_duid"],
|
||||||
weak=False,
|
weak=False,
|
||||||
|
|
@ -42,11 +57,26 @@ def set_actor(actor, remote_addr=None):
|
||||||
pre_save.disconnect(sender=LogEntry, dispatch_uid=auditlog["signal_duid"])
|
pre_save.disconnect(sender=LogEntry, dispatch_uid=auditlog["signal_duid"])
|
||||||
|
|
||||||
|
|
||||||
def _set_actor(user, sender, instance, signal_duid, **kwargs):
|
def _set_actor(auditlog, instance, sender):
|
||||||
|
LogEntry = get_logentry_model()
|
||||||
|
auth_user_model = get_user_model()
|
||||||
|
if "actor" in auditlog:
|
||||||
|
actor = auditlog.get("actor")
|
||||||
|
if (
|
||||||
|
sender == LogEntry
|
||||||
|
and isinstance(actor, auth_user_model)
|
||||||
|
and instance.actor is None
|
||||||
|
):
|
||||||
|
instance.actor = actor
|
||||||
|
instance.actor_email = getattr(actor, "email", None)
|
||||||
|
|
||||||
|
|
||||||
|
def _set_extra_data(sender, instance, signal_duid, **kwargs):
|
||||||
"""Signal receiver with extra 'user' and 'signal_duid' kwargs.
|
"""Signal receiver with extra 'user' and 'signal_duid' kwargs.
|
||||||
|
|
||||||
This function becomes a valid signal receiver when it is curried with the actor and a dispatch id.
|
This function becomes a valid signal receiver when it is curried with the actor and a dispatch id.
|
||||||
"""
|
"""
|
||||||
|
LogEntry = get_logentry_model()
|
||||||
try:
|
try:
|
||||||
auditlog = auditlog_value.get()
|
auditlog = auditlog_value.get()
|
||||||
except LookupError:
|
except LookupError:
|
||||||
|
|
@ -54,15 +84,15 @@ def _set_actor(user, sender, instance, signal_duid, **kwargs):
|
||||||
else:
|
else:
|
||||||
if signal_duid != auditlog["signal_duid"]:
|
if signal_duid != auditlog["signal_duid"]:
|
||||||
return
|
return
|
||||||
auth_user_model = get_user_model()
|
|
||||||
if (
|
|
||||||
sender == LogEntry
|
|
||||||
and isinstance(user, auth_user_model)
|
|
||||||
and instance.actor is None
|
|
||||||
):
|
|
||||||
instance.actor = user
|
|
||||||
|
|
||||||
instance.remote_addr = auditlog["remote_addr"]
|
_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
|
@contextlib.contextmanager
|
||||||
|
|
|
||||||
135
auditlog/diff.py
135
auditlog/diff.py
|
|
@ -1,12 +1,15 @@
|
||||||
import json
|
import json
|
||||||
|
from collections.abc import Callable
|
||||||
from datetime import timezone
|
from datetime import timezone
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.exceptions import ObjectDoesNotExist
|
from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist
|
||||||
from django.db.models import NOT_PROVIDED, DateTimeField, ForeignKey, JSONField, Model
|
from django.db.models import NOT_PROVIDED, DateTimeField, ForeignKey, JSONField, Model
|
||||||
from django.utils import timezone as django_timezone
|
from django.utils import timezone as django_timezone
|
||||||
from django.utils.encoding import smart_str
|
from django.utils.encoding import smart_str
|
||||||
|
from django.utils.module_loading import import_string
|
||||||
|
|
||||||
|
from auditlog import get_logentry_model
|
||||||
|
|
||||||
|
|
||||||
def track_field(field):
|
def track_field(field):
|
||||||
|
|
@ -20,7 +23,6 @@ def track_field(field):
|
||||||
:return: Whether the given field should be tracked.
|
:return: Whether the given field should be tracked.
|
||||||
:rtype: bool
|
:rtype: bool
|
||||||
"""
|
"""
|
||||||
from auditlog.models import LogEntry
|
|
||||||
|
|
||||||
# Do not track many to many relations
|
# Do not track many to many relations
|
||||||
if field.many_to_many:
|
if field.many_to_many:
|
||||||
|
|
@ -29,7 +31,7 @@ def track_field(field):
|
||||||
# Do not track relations to LogEntry
|
# Do not track relations to LogEntry
|
||||||
if (
|
if (
|
||||||
getattr(field, "remote_field", None) is not None
|
getattr(field, "remote_field", None) is not None
|
||||||
and field.remote_field.model == LogEntry
|
and field.remote_field.model == get_logentry_model()
|
||||||
):
|
):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
@ -51,7 +53,7 @@ def get_fields_in_model(instance):
|
||||||
return [f for f in instance._meta.get_fields() if track_field(f)]
|
return [f for f in instance._meta.get_fields() if track_field(f)]
|
||||||
|
|
||||||
|
|
||||||
def get_field_value(obj, field):
|
def get_field_value(obj, field, use_json_for_changes=False):
|
||||||
"""
|
"""
|
||||||
Gets the value of a given model instance field.
|
Gets the value of a given model instance field.
|
||||||
|
|
||||||
|
|
@ -62,11 +64,35 @@ def get_field_value(obj, field):
|
||||||
:return: The value of the field as a string.
|
:return: The value of the field as a string.
|
||||||
:rtype: str
|
: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
|
||||||
|
"""
|
||||||
|
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:
|
try:
|
||||||
if isinstance(field, DateTimeField):
|
if isinstance(field, DateTimeField):
|
||||||
# DateTimeFields are timezone-aware, so we need to convert the field
|
# DateTimeFields are timezone-aware, so we need to convert the field
|
||||||
# to its naive form before we can accurately compare them for changes.
|
# to its naive form before we can accurately compare them for changes.
|
||||||
value = field.to_python(getattr(obj, field.name, None))
|
value = getattr(obj, field.name)
|
||||||
|
try:
|
||||||
|
value = field.to_python(value)
|
||||||
|
except TypeError:
|
||||||
|
return value
|
||||||
if (
|
if (
|
||||||
value is not None
|
value is not None
|
||||||
and settings.USE_TZ
|
and settings.USE_TZ
|
||||||
|
|
@ -74,24 +100,65 @@ def get_field_value(obj, field):
|
||||||
):
|
):
|
||||||
value = django_timezone.make_naive(value, timezone=timezone.utc)
|
value = django_timezone.make_naive(value, timezone=timezone.utc)
|
||||||
elif isinstance(field, JSONField):
|
elif isinstance(field, JSONField):
|
||||||
value = field.to_python(getattr(obj, field.name, None))
|
value = field.to_python(getattr(obj, field.name))
|
||||||
value = json.dumps(value, sort_keys=True, cls=field.encoder)
|
if not use_json_for_changes:
|
||||||
elif (field.one_to_one or field.many_to_one) and hasattr(field, "rel_class"):
|
try:
|
||||||
value = smart_str(
|
value = json.dumps(value, sort_keys=True, cls=field.encoder)
|
||||||
getattr(obj, field.get_attname(), None), strings_only=True
|
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:
|
else:
|
||||||
value = smart_str(getattr(obj, field.name, None))
|
value = getattr(obj, field.name)
|
||||||
except ObjectDoesNotExist:
|
if not use_json_for_changes:
|
||||||
value = (
|
value = smart_str(value)
|
||||||
field.default
|
if type(value).__name__ == "__proxy__":
|
||||||
if getattr(field, "default", NOT_PROVIDED) is not NOT_PROVIDED
|
value = str(value)
|
||||||
else None
|
except (ObjectDoesNotExist, AttributeError):
|
||||||
)
|
return get_default_value()
|
||||||
|
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def is_primitive(obj) -> bool:
|
||||||
|
"""
|
||||||
|
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:
|
def mask_str(value: str) -> str:
|
||||||
"""
|
"""
|
||||||
Masks the first half of the input string to remove sensitive data.
|
Masks the first half of the input string to remove sensitive data.
|
||||||
|
|
@ -106,7 +173,10 @@ def mask_str(value: str) -> str:
|
||||||
|
|
||||||
|
|
||||||
def model_instance_diff(
|
def model_instance_diff(
|
||||||
old: Optional[Model], new: Optional[Model], fields_to_check=None
|
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``
|
Calculates the differences between two model instances. One of the instances may be ``None``
|
||||||
|
|
@ -119,6 +189,8 @@ def model_instance_diff(
|
||||||
:type new: Model
|
:type new: Model
|
||||||
:param fields_to_check: An iterable of the field names to restrict the diff to, while ignoring the rest of
|
: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.
|
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
|
: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
|
: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.
|
field values as value.
|
||||||
|
|
@ -180,17 +252,30 @@ def model_instance_diff(
|
||||||
fields = filtered_fields
|
fields = filtered_fields
|
||||||
|
|
||||||
for field in fields:
|
for field in fields:
|
||||||
old_value = get_field_value(old, field)
|
old_value = get_field_value(old, field, use_json_for_changes)
|
||||||
new_value = get_field_value(new, field)
|
new_value = get_field_value(new, field, use_json_for_changes)
|
||||||
|
|
||||||
if old_value != new_value:
|
if old_value != new_value:
|
||||||
if model_fields and field.name in model_fields["mask_fields"]:
|
if model_fields and field.name in model_fields["mask_fields"]:
|
||||||
|
mask_func = get_mask_function(model_fields.get("mask_callable"))
|
||||||
|
|
||||||
diff[field.name] = (
|
diff[field.name] = (
|
||||||
mask_str(smart_str(old_value)),
|
mask_func(smart_str(old_value)),
|
||||||
mask_str(smart_str(new_value)),
|
mask_func(smart_str(new_value)),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
diff[field.name] = (smart_str(old_value), smart_str(new_value))
|
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)
|
||||||
|
|
||||||
if len(diff) == 0:
|
if len(diff) == 0:
|
||||||
diff = None
|
diff = None
|
||||||
|
|
|
||||||
BIN
auditlog/locale/ja/LC_MESSAGES/django.mo
Normal file
BIN
auditlog/locale/ja/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
192
auditlog/locale/ja/LC_MESSAGES/django.po
Normal file
192
auditlog/locale/ja/LC_MESSAGES/django.po
Normal file
|
|
@ -0,0 +1,192 @@
|
||||||
|
# Django Auditlog Japanese Translation
|
||||||
|
# Copyright (C) 2025 Django Auditlog Contributors
|
||||||
|
# This file is distributed under the same license as the django-auditlog package.
|
||||||
|
# Youngkwang Yang <me@youngkwang.dev>, 2025.
|
||||||
|
#
|
||||||
|
msgid ""
|
||||||
|
msgstr ""
|
||||||
|
"Project-Id-Version: django-auditlog\n"
|
||||||
|
"Report-Msgid-Bugs-To: \n"
|
||||||
|
"POT-Creation-Date: 2025-09-28 03:43+0900\n"
|
||||||
|
"PO-Revision-Date: 2025-09-28 03:16+0900\n"
|
||||||
|
"Last-Translator: Youngkwang Yang <me@youngkwang.dev>\n"
|
||||||
|
"Language-Team: Japanese <ja@li.org>\n"
|
||||||
|
"Language: ja\n"
|
||||||
|
"MIME-Version: 1.0\n"
|
||||||
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
|
"Plural-Forms: nplurals=1; plural=0;\n"
|
||||||
|
|
||||||
|
#: admin.py mixins.py
|
||||||
|
msgid "Changes"
|
||||||
|
msgstr "変更"
|
||||||
|
|
||||||
|
#: apps.py mixins.py templates/auditlog/object_history.html
|
||||||
|
msgid "Audit log"
|
||||||
|
msgstr "監査ログ"
|
||||||
|
|
||||||
|
#: filters.py
|
||||||
|
msgid "Resource Type"
|
||||||
|
msgstr "リソースタイプ"
|
||||||
|
|
||||||
|
#: filters.py models.py
|
||||||
|
msgid "Correlation ID"
|
||||||
|
msgstr "Correlation ID"
|
||||||
|
|
||||||
|
#: mixins.py
|
||||||
|
msgid "Click to filter by records with this correlation id"
|
||||||
|
msgstr "このCorrelation IDでレコードをフィルタするにはクリックしてください"
|
||||||
|
|
||||||
|
#: mixins.py
|
||||||
|
msgid "Created"
|
||||||
|
msgstr "作成済み"
|
||||||
|
|
||||||
|
#: mixins.py
|
||||||
|
msgid "User"
|
||||||
|
msgstr "ユーザー"
|
||||||
|
|
||||||
|
#: mixins.py
|
||||||
|
msgid "Resource"
|
||||||
|
msgstr "リソース"
|
||||||
|
|
||||||
|
#: mixins.py
|
||||||
|
#, python-format
|
||||||
|
msgid "Audit log: %s"
|
||||||
|
msgstr "監査ログ:%s"
|
||||||
|
|
||||||
|
#: mixins.py
|
||||||
|
msgid "View"
|
||||||
|
msgstr "表示"
|
||||||
|
|
||||||
|
#: models.py
|
||||||
|
msgid "create"
|
||||||
|
msgstr "作成"
|
||||||
|
|
||||||
|
#: models.py
|
||||||
|
msgid "update"
|
||||||
|
msgstr "更新"
|
||||||
|
|
||||||
|
#: models.py
|
||||||
|
msgid "delete"
|
||||||
|
msgstr "削除"
|
||||||
|
|
||||||
|
#: models.py
|
||||||
|
msgid "access"
|
||||||
|
msgstr "アクセス"
|
||||||
|
|
||||||
|
#: models.py
|
||||||
|
msgid "content type"
|
||||||
|
msgstr "コンテンツタイプ"
|
||||||
|
|
||||||
|
#: models.py
|
||||||
|
msgid "object pk"
|
||||||
|
msgstr "オブジェクトPK"
|
||||||
|
|
||||||
|
#: models.py
|
||||||
|
msgid "object id"
|
||||||
|
msgstr "オブジェクトID"
|
||||||
|
|
||||||
|
#: models.py
|
||||||
|
msgid "object representation"
|
||||||
|
msgstr "オブジェクト表現"
|
||||||
|
|
||||||
|
#: models.py
|
||||||
|
msgid "action"
|
||||||
|
msgstr "アクション"
|
||||||
|
|
||||||
|
#: models.py
|
||||||
|
msgid "change message"
|
||||||
|
msgstr "変更メッセージ"
|
||||||
|
|
||||||
|
#: models.py
|
||||||
|
msgid "actor"
|
||||||
|
msgstr "アクター"
|
||||||
|
|
||||||
|
#: models.py
|
||||||
|
msgid "remote address"
|
||||||
|
msgstr "リモートアドレス"
|
||||||
|
|
||||||
|
#: models.py
|
||||||
|
msgid "remote port"
|
||||||
|
msgstr "リモートポート"
|
||||||
|
|
||||||
|
#: models.py
|
||||||
|
msgid "timestamp"
|
||||||
|
msgstr "タイムスタンプ"
|
||||||
|
|
||||||
|
#: models.py
|
||||||
|
msgid "additional data"
|
||||||
|
msgstr "追加データ"
|
||||||
|
|
||||||
|
#: models.py
|
||||||
|
msgid "actor email"
|
||||||
|
msgstr "アクターメール"
|
||||||
|
|
||||||
|
#: models.py
|
||||||
|
msgid "log entry"
|
||||||
|
msgstr "ログエントリ"
|
||||||
|
|
||||||
|
#: models.py
|
||||||
|
msgid "log entries"
|
||||||
|
msgstr "ログエントリ"
|
||||||
|
|
||||||
|
#: models.py
|
||||||
|
msgid "Created {repr:s}"
|
||||||
|
msgstr "{repr:s}が作成されました"
|
||||||
|
|
||||||
|
#: models.py
|
||||||
|
msgid "Updated {repr:s}"
|
||||||
|
msgstr "{repr:s}が更新されました"
|
||||||
|
|
||||||
|
#: models.py
|
||||||
|
msgid "Deleted {repr:s}"
|
||||||
|
msgstr "{repr:s}が削除されました"
|
||||||
|
|
||||||
|
#: models.py
|
||||||
|
msgid "Logged {repr:s}"
|
||||||
|
msgstr "{repr:s}がログに記録されました"
|
||||||
|
|
||||||
|
#: render.py
|
||||||
|
msgid "Field"
|
||||||
|
msgstr "フィールド"
|
||||||
|
|
||||||
|
#: render.py
|
||||||
|
msgid "From"
|
||||||
|
msgstr "変更前の値"
|
||||||
|
|
||||||
|
#: render.py
|
||||||
|
msgid "To"
|
||||||
|
msgstr "変更後の値"
|
||||||
|
|
||||||
|
#: render.py
|
||||||
|
msgid "Relationship"
|
||||||
|
msgstr "関係"
|
||||||
|
|
||||||
|
#: render.py
|
||||||
|
msgid "Action"
|
||||||
|
msgstr "アクション"
|
||||||
|
|
||||||
|
#: render.py
|
||||||
|
msgid "Objects"
|
||||||
|
msgstr "オブジェクト一覧"
|
||||||
|
|
||||||
|
#: templates/auditlog/entry_detail.html
|
||||||
|
msgid "system"
|
||||||
|
msgstr "システム"
|
||||||
|
|
||||||
|
#: templates/auditlog/entry_detail.html
|
||||||
|
msgid "No field changes"
|
||||||
|
msgstr "フィールドの変更なし"
|
||||||
|
|
||||||
|
#: templates/auditlog/object_history.html
|
||||||
|
msgid "Home"
|
||||||
|
msgstr "ホーム"
|
||||||
|
|
||||||
|
#: templates/auditlog/object_history.html
|
||||||
|
msgid "No log entries found."
|
||||||
|
msgstr "ログエントリが見つかりません。"
|
||||||
|
|
||||||
|
#: templates/auditlog/pagination.html
|
||||||
|
msgid "entry"
|
||||||
|
msgid_plural "entries"
|
||||||
|
msgstr[0] "ログエントリ"
|
||||||
BIN
auditlog/locale/ko/LC_MESSAGES/django.mo
Normal file
BIN
auditlog/locale/ko/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
192
auditlog/locale/ko/LC_MESSAGES/django.po
Normal file
192
auditlog/locale/ko/LC_MESSAGES/django.po
Normal file
|
|
@ -0,0 +1,192 @@
|
||||||
|
# Django Auditlog Korean Translation
|
||||||
|
# Copyright (C) 2025 Django Auditlog Contributors
|
||||||
|
# This file is distributed under the same license as the django-auditlog package.
|
||||||
|
# Youngkwang Yang <me@youngkwang.dev>, 2025.
|
||||||
|
#
|
||||||
|
msgid ""
|
||||||
|
msgstr ""
|
||||||
|
"Project-Id-Version: django-auditlog\n"
|
||||||
|
"Report-Msgid-Bugs-To: \n"
|
||||||
|
"POT-Creation-Date: 2025-09-28 03:43+0900\n"
|
||||||
|
"PO-Revision-Date: 2025-09-28 02:55+0900\n"
|
||||||
|
"Last-Translator: Youngkwang Yang <me@youngkwang.dev>\n"
|
||||||
|
"Language-Team: Korean <ko@li.org>\n"
|
||||||
|
"Language: ko\n"
|
||||||
|
"MIME-Version: 1.0\n"
|
||||||
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
|
"Plural-Forms: nplurals=1; plural=0;\n"
|
||||||
|
|
||||||
|
#: admin.py mixins.py
|
||||||
|
msgid "Changes"
|
||||||
|
msgstr "변경 사항"
|
||||||
|
|
||||||
|
#: apps.py mixins.py templates/auditlog/object_history.html
|
||||||
|
msgid "Audit log"
|
||||||
|
msgstr "감사 로그"
|
||||||
|
|
||||||
|
#: filters.py
|
||||||
|
msgid "Resource Type"
|
||||||
|
msgstr "리소스 타입"
|
||||||
|
|
||||||
|
#: filters.py models.py
|
||||||
|
msgid "Correlation ID"
|
||||||
|
msgstr "Correlation ID"
|
||||||
|
|
||||||
|
#: mixins.py
|
||||||
|
msgid "Click to filter by records with this correlation id"
|
||||||
|
msgstr "이 Correlation ID로 레코드를 필터링하려면 클릭하세요"
|
||||||
|
|
||||||
|
#: mixins.py
|
||||||
|
msgid "Created"
|
||||||
|
msgstr "생성됨"
|
||||||
|
|
||||||
|
#: mixins.py
|
||||||
|
msgid "User"
|
||||||
|
msgstr "사용자"
|
||||||
|
|
||||||
|
#: mixins.py
|
||||||
|
msgid "Resource"
|
||||||
|
msgstr "리소스"
|
||||||
|
|
||||||
|
#: mixins.py
|
||||||
|
#, python-format
|
||||||
|
msgid "Audit log: %s"
|
||||||
|
msgstr "감사 로그: %s"
|
||||||
|
|
||||||
|
#: mixins.py
|
||||||
|
msgid "View"
|
||||||
|
msgstr "보기"
|
||||||
|
|
||||||
|
#: models.py
|
||||||
|
msgid "create"
|
||||||
|
msgstr "생성"
|
||||||
|
|
||||||
|
#: models.py
|
||||||
|
msgid "update"
|
||||||
|
msgstr "수정"
|
||||||
|
|
||||||
|
#: models.py
|
||||||
|
msgid "delete"
|
||||||
|
msgstr "삭제"
|
||||||
|
|
||||||
|
#: models.py
|
||||||
|
msgid "access"
|
||||||
|
msgstr "접근"
|
||||||
|
|
||||||
|
#: models.py
|
||||||
|
msgid "content type"
|
||||||
|
msgstr "콘텐츠 타입"
|
||||||
|
|
||||||
|
#: models.py
|
||||||
|
msgid "object pk"
|
||||||
|
msgstr "객체 PK"
|
||||||
|
|
||||||
|
#: models.py
|
||||||
|
msgid "object id"
|
||||||
|
msgstr "객체 ID"
|
||||||
|
|
||||||
|
#: models.py
|
||||||
|
msgid "object representation"
|
||||||
|
msgstr "객체 표현"
|
||||||
|
|
||||||
|
#: models.py
|
||||||
|
msgid "action"
|
||||||
|
msgstr "작업"
|
||||||
|
|
||||||
|
#: models.py
|
||||||
|
msgid "change message"
|
||||||
|
msgstr "변경 메시지"
|
||||||
|
|
||||||
|
#: models.py
|
||||||
|
msgid "actor"
|
||||||
|
msgstr "작업자"
|
||||||
|
|
||||||
|
#: models.py
|
||||||
|
msgid "remote address"
|
||||||
|
msgstr "원격 주소"
|
||||||
|
|
||||||
|
#: models.py
|
||||||
|
msgid "remote port"
|
||||||
|
msgstr "원격 포트"
|
||||||
|
|
||||||
|
#: models.py
|
||||||
|
msgid "timestamp"
|
||||||
|
msgstr "타임스탬프"
|
||||||
|
|
||||||
|
#: models.py
|
||||||
|
msgid "additional data"
|
||||||
|
msgstr "추가 데이터"
|
||||||
|
|
||||||
|
#: models.py
|
||||||
|
msgid "actor email"
|
||||||
|
msgstr "작업자 이메일"
|
||||||
|
|
||||||
|
#: models.py
|
||||||
|
msgid "log entry"
|
||||||
|
msgstr "로그 항목"
|
||||||
|
|
||||||
|
#: models.py
|
||||||
|
msgid "log entries"
|
||||||
|
msgstr "로그 항목들"
|
||||||
|
|
||||||
|
#: models.py
|
||||||
|
msgid "Created {repr:s}"
|
||||||
|
msgstr "{repr:s}이(가) 생성됨"
|
||||||
|
|
||||||
|
#: models.py
|
||||||
|
msgid "Updated {repr:s}"
|
||||||
|
msgstr "{repr:s}이(가) 수정됨"
|
||||||
|
|
||||||
|
#: models.py
|
||||||
|
msgid "Deleted {repr:s}"
|
||||||
|
msgstr "{repr:s}이(가) 삭제됨"
|
||||||
|
|
||||||
|
#: models.py
|
||||||
|
msgid "Logged {repr:s}"
|
||||||
|
msgstr "{repr:s}이(가) 기록됨"
|
||||||
|
|
||||||
|
#: render.py
|
||||||
|
msgid "Field"
|
||||||
|
msgstr "필드"
|
||||||
|
|
||||||
|
#: render.py
|
||||||
|
msgid "From"
|
||||||
|
msgstr "변경 전"
|
||||||
|
|
||||||
|
#: render.py
|
||||||
|
msgid "To"
|
||||||
|
msgstr "변경 후"
|
||||||
|
|
||||||
|
#: render.py
|
||||||
|
msgid "Relationship"
|
||||||
|
msgstr "관계"
|
||||||
|
|
||||||
|
#: render.py
|
||||||
|
msgid "Action"
|
||||||
|
msgstr "작업"
|
||||||
|
|
||||||
|
#: render.py
|
||||||
|
msgid "Objects"
|
||||||
|
msgstr "객체"
|
||||||
|
|
||||||
|
#: templates/auditlog/entry_detail.html
|
||||||
|
msgid "system"
|
||||||
|
msgstr "시스템"
|
||||||
|
|
||||||
|
#: templates/auditlog/entry_detail.html
|
||||||
|
msgid "No field changes"
|
||||||
|
msgstr "필드 변경 사항 없음"
|
||||||
|
|
||||||
|
#: templates/auditlog/object_history.html
|
||||||
|
msgid "Home"
|
||||||
|
msgstr "홈"
|
||||||
|
|
||||||
|
#: templates/auditlog/object_history.html
|
||||||
|
msgid "No log entries found."
|
||||||
|
msgstr "로그 항목을 찾을 수 없습니다."
|
||||||
|
|
||||||
|
#: templates/auditlog/pagination.html
|
||||||
|
msgid "entry"
|
||||||
|
msgid_plural "entries"
|
||||||
|
msgstr[0] "항목"
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.db import connection
|
||||||
|
|
||||||
from auditlog.models import LogEntry
|
from auditlog import get_logentry_model
|
||||||
|
|
||||||
|
LogEntry = get_logentry_model()
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
|
|
@ -25,11 +28,24 @@ class Command(BaseCommand):
|
||||||
dest="before_date",
|
dest="before_date",
|
||||||
type=datetime.date.fromisoformat,
|
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):
|
def handle(self, *args, **options):
|
||||||
answer = options["yes"]
|
answer = options["yes"]
|
||||||
|
truncate = options["truncate"]
|
||||||
before = options["before_date"]
|
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:
|
if answer is None:
|
||||||
warning_message = (
|
warning_message = (
|
||||||
"This action will clear all log entries from the database."
|
"This action will clear all log entries from the database."
|
||||||
|
|
@ -42,11 +58,39 @@ class Command(BaseCommand):
|
||||||
)
|
)
|
||||||
answer = response == "y"
|
answer = response == "y"
|
||||||
|
|
||||||
if answer:
|
if not answer:
|
||||||
|
self.stdout.write("Aborted.")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not truncate:
|
||||||
entries = LogEntry.objects.all()
|
entries = LogEntry.objects.all()
|
||||||
if before is not None:
|
if before is not None:
|
||||||
entries = entries.filter(timestamp__date__lt=before)
|
entries = entries.filter(timestamp__date__lt=before)
|
||||||
count, _ = entries.delete()
|
count, _ = entries.delete()
|
||||||
self.stdout.write("Deleted %d objects." % count)
|
self.stdout.write("Deleted %d objects." % count)
|
||||||
else:
|
else:
|
||||||
self.stdout.write("Aborted.")
|
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};"
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,9 @@ from django.conf import settings
|
||||||
from django.core.management import CommandError, CommandParser
|
from django.core.management import CommandError, CommandParser
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
from auditlog.models import LogEntry
|
from auditlog import get_logentry_model
|
||||||
|
|
||||||
|
LogEntry = get_logentry_model()
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
|
|
@ -124,9 +126,13 @@ class Command(BaseCommand):
|
||||||
|
|
||||||
def postgres():
|
def postgres():
|
||||||
with connection.cursor() as cursor:
|
with connection.cursor() as cursor:
|
||||||
cursor.execute(
|
cursor.execute(f"""
|
||||||
'UPDATE auditlog_logentry SET changes="changes_text"::jsonb'
|
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
|
return cursor.cursor.rowcount
|
||||||
|
|
||||||
if database == "postgres":
|
if database == "postgres":
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
|
from django.conf import settings
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
from auditlog.cid import set_cid
|
from auditlog.cid import set_cid
|
||||||
from auditlog.context import set_actor
|
from auditlog.context import set_extra_data
|
||||||
|
|
||||||
|
|
||||||
class AuditlogMiddleware:
|
class AuditlogMiddleware:
|
||||||
|
|
@ -12,9 +13,14 @@ class AuditlogMiddleware:
|
||||||
|
|
||||||
def __init__(self, get_response=None):
|
def __init__(self, get_response=None):
|
||||||
self.get_response = get_response
|
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
|
@staticmethod
|
||||||
def _get_remote_addr(request):
|
def _get_remote_addr(request):
|
||||||
|
if settings.AUDITLOG_DISABLE_REMOTE_ADDR:
|
||||||
|
return None
|
||||||
|
|
||||||
# In case there is no proxy, return the original address
|
# In case there is no proxy, return the original address
|
||||||
if not request.headers.get("X-Forwarded-For"):
|
if not request.headers.get("X-Forwarded-For"):
|
||||||
return request.META.get("REMOTE_ADDR")
|
return request.META.get("REMOTE_ADDR")
|
||||||
|
|
@ -30,6 +36,17 @@ class AuditlogMiddleware:
|
||||||
|
|
||||||
return remote_addr
|
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
|
@staticmethod
|
||||||
def _get_actor(request):
|
def _get_actor(request):
|
||||||
user = getattr(request, "user", None)
|
user = getattr(request, "user", None)
|
||||||
|
|
@ -37,11 +54,17 @@ class AuditlogMiddleware:
|
||||||
return user
|
return user
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def __call__(self, request):
|
def get_extra_data(self, request):
|
||||||
remote_addr = self._get_remote_addr(request)
|
context_data = {}
|
||||||
user = self._get_actor(request)
|
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)
|
set_cid(request)
|
||||||
|
|
||||||
with set_actor(actor=user, remote_addr=remote_addr):
|
with set_extra_data(context_data=self.get_extra_data(request)):
|
||||||
return self.get_response(request)
|
return self.get_response(request)
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,9 @@
|
||||||
# Generated by Django 4.0 on 2022-08-04 15:41
|
# Generated by Django 4.0 on 2022-08-04 15:41
|
||||||
from typing import List
|
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
def two_step_migrations() -> List:
|
def two_step_migrations() -> list:
|
||||||
if settings.AUDITLOG_TWO_STEP_MIGRATION:
|
if settings.AUDITLOG_TWO_STEP_MIGRATION:
|
||||||
return [
|
return [
|
||||||
migrations.RenameField(
|
migrations.RenameField(
|
||||||
|
|
|
||||||
17
auditlog/migrations/0016_logentry_remote_port.py
Normal file
17
auditlog/migrations/0016_logentry_remote_port.py
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
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"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
20
auditlog/migrations/0017_add_actor_email.py
Normal file
20
auditlog/migrations/0017_add_actor_email.py
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -1,19 +1,25 @@
|
||||||
|
from urllib.parse import unquote
|
||||||
|
|
||||||
from django import urls as urlresolvers
|
from django import urls as urlresolvers
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.core.exceptions import FieldDoesNotExist
|
from django.contrib.admin.views.main import PAGE_VAR
|
||||||
from django.forms.utils import pretty_name
|
from django.core.exceptions import PermissionDenied
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
|
from django.template.response import TemplateResponse
|
||||||
|
from django.urls import path, reverse
|
||||||
from django.urls.exceptions import NoReverseMatch
|
from django.urls.exceptions import NoReverseMatch
|
||||||
from django.utils.html import format_html, format_html_join
|
from django.utils.html import format_html
|
||||||
from django.utils.safestring import mark_safe
|
from django.utils.text import capfirst
|
||||||
from django.utils.timezone import is_aware, localtime
|
from django.utils.timezone import is_aware, localtime
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from auditlog.models import LogEntry
|
from auditlog import get_logentry_model
|
||||||
from auditlog.registry import auditlog
|
from auditlog.render import get_field_verbose_name, render_logentry_changes_html
|
||||||
from auditlog.signals import accessed
|
from auditlog.signals import accessed
|
||||||
|
|
||||||
|
LogEntry = get_logentry_model()
|
||||||
|
|
||||||
MAX = 75
|
MAX = 75
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -68,55 +74,7 @@ class LogEntryAdminMixin:
|
||||||
|
|
||||||
@admin.display(description=_("Changes"))
|
@admin.display(description=_("Changes"))
|
||||||
def msg(self, obj):
|
def msg(self, obj):
|
||||||
changes = obj.changes_dict
|
return render_logentry_changes_html(obj)
|
||||||
|
|
||||||
atom_changes = {}
|
|
||||||
m2m_changes = {}
|
|
||||||
|
|
||||||
for field, change in changes.items():
|
|
||||||
if isinstance(change, dict):
|
|
||||||
assert (
|
|
||||||
change["type"] == "m2m"
|
|
||||||
), "Only m2m operations are expected to produce dict changes now"
|
|
||||||
m2m_changes[field] = change
|
|
||||||
else:
|
|
||||||
atom_changes[field] = change
|
|
||||||
|
|
||||||
msg = []
|
|
||||||
|
|
||||||
if atom_changes:
|
|
||||||
msg.append("<table>")
|
|
||||||
msg.append(self._format_header("#", "Field", "From", "To"))
|
|
||||||
for i, (field, change) in enumerate(sorted(atom_changes.items()), 1):
|
|
||||||
value = [i, self.field_verbose_name(obj, field)] + (
|
|
||||||
["***", "***"] if field == "password" else change
|
|
||||||
)
|
|
||||||
msg.append(self._format_line(*value))
|
|
||||||
msg.append("</table>")
|
|
||||||
|
|
||||||
if m2m_changes:
|
|
||||||
msg.append("<table>")
|
|
||||||
msg.append(self._format_header("#", "Relationship", "Action", "Objects"))
|
|
||||||
for i, (field, change) in enumerate(sorted(m2m_changes.items()), 1):
|
|
||||||
change_html = format_html_join(
|
|
||||||
mark_safe("<br>"),
|
|
||||||
"{}",
|
|
||||||
[(value,) for value in change["objects"]],
|
|
||||||
)
|
|
||||||
|
|
||||||
msg.append(
|
|
||||||
format_html(
|
|
||||||
"<tr><td>{}</td><td>{}</td><td>{}</td><td>{}</td></tr>",
|
|
||||||
i,
|
|
||||||
self.field_verbose_name(obj, field),
|
|
||||||
change["operation"],
|
|
||||||
change_html,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
msg.append("</table>")
|
|
||||||
|
|
||||||
return mark_safe("".join(msg))
|
|
||||||
|
|
||||||
@admin.display(description="Correlation ID")
|
@admin.display(description="Correlation ID")
|
||||||
def cid_url(self, obj):
|
def cid_url(self, obj):
|
||||||
|
|
@ -127,43 +85,95 @@ class LogEntryAdminMixin:
|
||||||
'<a href="{}" title="{}">{}</a>', url, self.CID_TITLE, cid
|
'<a href="{}" title="{}">{}</a>', url, self.CID_TITLE, cid
|
||||||
)
|
)
|
||||||
|
|
||||||
def _format_header(self, *labels):
|
|
||||||
return format_html(
|
|
||||||
"".join(["<tr>", "<th>{}</th>" * len(labels), "</tr>"]), *labels
|
|
||||||
)
|
|
||||||
|
|
||||||
def _format_line(self, *values):
|
|
||||||
return format_html(
|
|
||||||
"".join(["<tr>", "<td>{}</td>" * len(values), "</tr>"]), *values
|
|
||||||
)
|
|
||||||
|
|
||||||
def field_verbose_name(self, obj, field_name: str):
|
|
||||||
model = obj.content_type.model_class()
|
|
||||||
if model is None:
|
|
||||||
return field_name
|
|
||||||
try:
|
|
||||||
model_fields = auditlog.get_model_fields(model._meta.model)
|
|
||||||
mapping_field_name = model_fields["mapping_fields"].get(field_name)
|
|
||||||
if mapping_field_name:
|
|
||||||
return mapping_field_name
|
|
||||||
except KeyError:
|
|
||||||
# Model definition in auditlog was probably removed
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
field = model._meta.get_field(field_name)
|
|
||||||
return pretty_name(getattr(field, "verbose_name", field_name))
|
|
||||||
except FieldDoesNotExist:
|
|
||||||
return pretty_name(field_name)
|
|
||||||
|
|
||||||
def _add_query_parameter(self, key: str, value: str):
|
def _add_query_parameter(self, key: str, value: str):
|
||||||
full_path = self.request.get_full_path()
|
full_path = self.request.get_full_path()
|
||||||
delimiter = "&" if "?" in full_path else "?"
|
delimiter = "&" if "?" in full_path else "?"
|
||||||
|
|
||||||
return f"{full_path}{delimiter}{key}={value}"
|
return f"{full_path}{delimiter}{key}={value}"
|
||||||
|
|
||||||
|
def field_verbose_name(self, obj, field_name: str):
|
||||||
|
"""
|
||||||
|
Use `auditlog.render.get_field_verbose_name` instead.
|
||||||
|
This method is kept for backward compatibility.
|
||||||
|
"""
|
||||||
|
return get_field_verbose_name(obj, field_name)
|
||||||
|
|
||||||
|
|
||||||
class LogAccessMixin:
|
class LogAccessMixin:
|
||||||
def render_to_response(self, context, **response_kwargs):
|
def render_to_response(self, context, **response_kwargs):
|
||||||
obj = self.get_object()
|
obj = self.get_object()
|
||||||
accessed.send(obj.__class__, instance=obj)
|
accessed.send(obj.__class__, instance=obj)
|
||||||
return super().render_to_response(context, **response_kwargs)
|
return super().render_to_response(context, **response_kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class AuditlogHistoryAdminMixin:
|
||||||
|
"""
|
||||||
|
Add an audit log history view to a model admin.
|
||||||
|
"""
|
||||||
|
|
||||||
|
auditlog_history_template = "auditlog/object_history.html"
|
||||||
|
show_auditlog_history_link = False
|
||||||
|
auditlog_history_per_page = 10
|
||||||
|
|
||||||
|
def get_list_display(self, request):
|
||||||
|
list_display = list(super().get_list_display(request))
|
||||||
|
if self.show_auditlog_history_link and "auditlog_link" not in list_display:
|
||||||
|
list_display.append("auditlog_link")
|
||||||
|
|
||||||
|
return list_display
|
||||||
|
|
||||||
|
def get_urls(self):
|
||||||
|
opts = self.model._meta
|
||||||
|
info = opts.app_label, opts.model_name
|
||||||
|
my_urls = [
|
||||||
|
path(
|
||||||
|
"<path:object_id>/auditlog/",
|
||||||
|
self.admin_site.admin_view(self.auditlog_history_view),
|
||||||
|
name="%s_%s_auditlog" % info,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
return my_urls + super().get_urls()
|
||||||
|
|
||||||
|
def auditlog_history_view(self, request, object_id, extra_context=None):
|
||||||
|
obj = self.get_object(request, unquote(object_id))
|
||||||
|
if not self.has_view_permission(request, obj):
|
||||||
|
raise PermissionDenied
|
||||||
|
|
||||||
|
log_entries = (
|
||||||
|
LogEntry.objects.get_for_object(obj)
|
||||||
|
.select_related("actor")
|
||||||
|
.order_by("-timestamp")
|
||||||
|
)
|
||||||
|
|
||||||
|
paginator = self.get_paginator(
|
||||||
|
request, log_entries, self.auditlog_history_per_page
|
||||||
|
)
|
||||||
|
page_number = request.GET.get(PAGE_VAR, 1)
|
||||||
|
page_obj = paginator.get_page(page_number)
|
||||||
|
page_range = paginator.get_elided_page_range(page_obj.number)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
**self.admin_site.each_context(request),
|
||||||
|
"title": _("Audit log: %s") % obj,
|
||||||
|
"module_name": str(capfirst(self.model._meta.verbose_name_plural)),
|
||||||
|
"page_range": page_range,
|
||||||
|
"page_var": PAGE_VAR,
|
||||||
|
"pagination_required": paginator.count > self.auditlog_history_per_page,
|
||||||
|
"object": obj,
|
||||||
|
"opts": self.model._meta,
|
||||||
|
"log_entries": page_obj,
|
||||||
|
**(extra_context or {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
return TemplateResponse(request, self.auditlog_history_template, context)
|
||||||
|
|
||||||
|
@admin.display(description=_("Audit log"))
|
||||||
|
def auditlog_link(self, obj):
|
||||||
|
opts = self.model._meta
|
||||||
|
url = reverse(
|
||||||
|
f"admin:{opts.app_label}_{opts.model_name}_auditlog",
|
||||||
|
args=[obj.pk],
|
||||||
|
)
|
||||||
|
|
||||||
|
return format_html('<a href="{}">{}</a>', url, _("View"))
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
import ast
|
import ast
|
||||||
import contextlib
|
import contextlib
|
||||||
import json
|
import json
|
||||||
|
from collections.abc import Callable
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from datetime import timezone
|
from datetime import timezone
|
||||||
from typing import Any, Callable, Dict, List, Union
|
from typing import Any
|
||||||
|
|
||||||
from dateutil import parser
|
from dateutil import parser
|
||||||
from dateutil.tz import gettz
|
from dateutil.tz import gettz
|
||||||
|
|
@ -23,7 +24,8 @@ from django.utils import timezone as django_timezone
|
||||||
from django.utils.encoding import smart_str
|
from django.utils.encoding import smart_str
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from auditlog.diff import mask_str
|
from auditlog import get_logentry_model
|
||||||
|
from auditlog.diff import get_mask_function
|
||||||
|
|
||||||
DEFAULT_OBJECT_REPR = "<error forming object repr>"
|
DEFAULT_OBJECT_REPR = "<error forming object repr>"
|
||||||
|
|
||||||
|
|
@ -213,17 +215,21 @@ class LogEntryManager(models.Manager):
|
||||||
:type instance: Model
|
:type instance: Model
|
||||||
:return: The primary key value of the given model instance.
|
:return: The primary key value of the given model instance.
|
||||||
"""
|
"""
|
||||||
pk_field = instance._meta.pk.name
|
# Should be equivalent to `instance.pk`.
|
||||||
|
pk_field = instance._meta.pk.attname
|
||||||
pk = getattr(instance, pk_field, None)
|
pk = getattr(instance, pk_field, None)
|
||||||
|
|
||||||
# Check to make sure that we got a pk not a model object.
|
# Check to make sure that we got a pk not a model object.
|
||||||
if isinstance(pk, models.Model):
|
# Should be guaranteed as we used `attname` above, not `name`.
|
||||||
pk = self._get_pk_value(pk)
|
assert not isinstance(pk, models.Model)
|
||||||
return pk
|
return pk
|
||||||
|
|
||||||
def _get_serialized_data_or_none(self, instance):
|
def _get_serialized_data_or_none(self, instance):
|
||||||
from auditlog.registry import auditlog
|
from auditlog.registry import auditlog
|
||||||
|
|
||||||
|
if not auditlog.contains(instance.__class__):
|
||||||
|
return None
|
||||||
|
|
||||||
opts = auditlog.get_serialize_options(instance.__class__)
|
opts = auditlog.get_serialize_options(instance.__class__)
|
||||||
if not opts["serialize_data"]:
|
if not opts["serialize_data"]:
|
||||||
return None
|
return None
|
||||||
|
|
@ -243,7 +249,7 @@ class LogEntryManager(models.Manager):
|
||||||
|
|
||||||
mask_fields = model_fields["mask_fields"]
|
mask_fields = model_fields["mask_fields"]
|
||||||
if mask_fields:
|
if mask_fields:
|
||||||
data = self._mask_serialized_fields(data, mask_fields)
|
data = self._mask_serialized_fields(data, mask_fields, model_fields)
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
@ -271,8 +277,8 @@ class LogEntryManager(models.Manager):
|
||||||
return instance_copy
|
return instance_copy
|
||||||
|
|
||||||
def _get_applicable_model_fields(
|
def _get_applicable_model_fields(
|
||||||
self, instance, model_fields: Dict[str, List[str]]
|
self, instance, model_fields: dict[str, list[str]]
|
||||||
) -> List[str]:
|
) -> list[str]:
|
||||||
include_fields = model_fields["include_fields"]
|
include_fields = model_fields["include_fields"]
|
||||||
exclude_fields = model_fields["exclude_fields"]
|
exclude_fields = model_fields["exclude_fields"]
|
||||||
all_field_names = [field.name for field in instance._meta.fields]
|
all_field_names = [field.name for field in instance._meta.fields]
|
||||||
|
|
@ -283,14 +289,15 @@ class LogEntryManager(models.Manager):
|
||||||
return list(set(include_fields or all_field_names).difference(exclude_fields))
|
return list(set(include_fields or all_field_names).difference(exclude_fields))
|
||||||
|
|
||||||
def _mask_serialized_fields(
|
def _mask_serialized_fields(
|
||||||
self, data: Dict[str, Any], mask_fields: List[str]
|
self, data: dict[str, Any], mask_fields: list[str], model_fields: dict[str, Any]
|
||||||
) -> Dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
all_field_data = data.pop("fields")
|
all_field_data = data.pop("fields")
|
||||||
|
mask_func = get_mask_function(model_fields.get("mask_callable"))
|
||||||
|
|
||||||
masked_field_data = {}
|
masked_field_data = {}
|
||||||
for key, value in all_field_data.items():
|
for key, value in all_field_data.items():
|
||||||
if isinstance(value, str) and key in mask_fields:
|
if isinstance(value, str) and key in mask_fields:
|
||||||
masked_field_data[key] = mask_str(value)
|
masked_field_data[key] = mask_func(value)
|
||||||
else:
|
else:
|
||||||
masked_field_data[key] = value
|
masked_field_data[key] = value
|
||||||
|
|
||||||
|
|
@ -298,7 +305,7 @@ class LogEntryManager(models.Manager):
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
class LogEntry(models.Model):
|
class AbstractLogEntry(models.Model):
|
||||||
"""
|
"""
|
||||||
Represents an entry in the audit log. The content type is saved along with the textual and numeric
|
Represents an entry in the audit log. The content type is saved along with the textual and numeric
|
||||||
(if available) primary key, as well as the textual representation of the object when it was saved.
|
(if available) primary key, as well as the textual representation of the object when it was saved.
|
||||||
|
|
@ -369,6 +376,9 @@ class LogEntry(models.Model):
|
||||||
remote_addr = models.GenericIPAddressField(
|
remote_addr = models.GenericIPAddressField(
|
||||||
blank=True, null=True, verbose_name=_("remote address")
|
blank=True, null=True, verbose_name=_("remote address")
|
||||||
)
|
)
|
||||||
|
remote_port = models.PositiveIntegerField(
|
||||||
|
blank=True, null=True, verbose_name=_("remote port")
|
||||||
|
)
|
||||||
timestamp = models.DateTimeField(
|
timestamp = models.DateTimeField(
|
||||||
default=django_timezone.now,
|
default=django_timezone.now,
|
||||||
db_index=True,
|
db_index=True,
|
||||||
|
|
@ -377,10 +387,14 @@ class LogEntry(models.Model):
|
||||||
additional_data = models.JSONField(
|
additional_data = models.JSONField(
|
||||||
blank=True, null=True, verbose_name=_("additional data")
|
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()
|
objects = LogEntryManager()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
abstract = True
|
||||||
get_latest_by = "timestamp"
|
get_latest_by = "timestamp"
|
||||||
ordering = ["-timestamp"]
|
ordering = ["-timestamp"]
|
||||||
verbose_name = _("log entry")
|
verbose_name = _("log entry")
|
||||||
|
|
@ -413,21 +427,29 @@ class LogEntry(models.Model):
|
||||||
not satisfying, please use :py:func:`LogEntry.changes_dict` and format the string yourself.
|
not satisfying, please use :py:func:`LogEntry.changes_dict` and format the string yourself.
|
||||||
|
|
||||||
:param colon: The string to place between the field name and the values.
|
:param colon: The string to place between the field name and the values.
|
||||||
:param arrow: The string to place between each old and new value.
|
:param arrow: The string to place between each old and new value (non-m2m field changes only).
|
||||||
:param separator: The string to place between each field.
|
:param separator: The string to place between each field.
|
||||||
:return: A readable string of the changes in this log entry.
|
:return: A readable string of the changes in this log entry.
|
||||||
"""
|
"""
|
||||||
substrings = []
|
substrings = []
|
||||||
|
|
||||||
for field, values in self.changes_dict.items():
|
for field, value in sorted(self.changes_dict.items()):
|
||||||
substring = "{field_name:s}{colon:s}{old:s}{arrow:s}{new:s}".format(
|
if isinstance(value, (list, tuple)) and len(value) == 2:
|
||||||
field_name=field,
|
# handle regular field change
|
||||||
colon=colon,
|
substring = "{field_name:s}{colon:s}{old:s}{arrow:s}{new:s}".format(
|
||||||
old=values[0],
|
field_name=field,
|
||||||
arrow=arrow,
|
colon=colon,
|
||||||
new=values[1],
|
old=value[0],
|
||||||
)
|
arrow=arrow,
|
||||||
substrings.append(substring)
|
new=value[1],
|
||||||
|
)
|
||||||
|
substrings.append(substring)
|
||||||
|
elif isinstance(value, dict) and value.get("type") == "m2m":
|
||||||
|
# handle m2m change
|
||||||
|
substring = (
|
||||||
|
f"{field}{colon}{value['operation']} {sorted(value['objects'])}"
|
||||||
|
)
|
||||||
|
substrings.append(substring)
|
||||||
|
|
||||||
return separator.join(substrings)
|
return separator.join(substrings)
|
||||||
|
|
||||||
|
|
@ -436,14 +458,25 @@ class LogEntry(models.Model):
|
||||||
"""
|
"""
|
||||||
:return: The changes recorded in this log entry intended for display to users as a dictionary object.
|
: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
|
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 = self.content_type.model_class()
|
||||||
model_fields = auditlog.get_model_fields(model._meta.model)
|
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
|
||||||
|
|
||||||
changes_display_dict = {}
|
changes_display_dict = {}
|
||||||
# grab the changes_dict and iterate through
|
# grab the changes_dict and iterate through
|
||||||
for field_name, values in self.changes_dict.items():
|
for field_name, values in changes_dict.items():
|
||||||
# try to get the field attribute on the model
|
# try to get the field attribute on the model
|
||||||
try:
|
try:
|
||||||
field = model._meta.get_field(field_name)
|
field = model._meta.get_field(field_name)
|
||||||
|
|
@ -496,19 +529,23 @@ class LogEntry(models.Model):
|
||||||
elif field_type in ["ForeignKey", "OneToOneField"]:
|
elif field_type in ["ForeignKey", "OneToOneField"]:
|
||||||
value = self._get_changes_display_for_fk_field(field, value)
|
value = self._get_changes_display_for_fk_field(field, value)
|
||||||
|
|
||||||
# check if length is longer than 140 and truncate with ellipsis
|
truncate_at = settings.AUDITLOG_CHANGE_DISPLAY_TRUNCATE_LENGTH
|
||||||
if len(value) > 140:
|
if 0 <= truncate_at < len(value):
|
||||||
value = f"{value[:140]}..."
|
value = value[:truncate_at] + ("..." if truncate_at > 0 else "")
|
||||||
|
|
||||||
values_display.append(value)
|
values_display.append(value)
|
||||||
verbose_name = model_fields["mapping_fields"].get(
|
|
||||||
field.name, getattr(field, "verbose_name", field.name)
|
# 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)
|
||||||
|
|
||||||
changes_display_dict[verbose_name] = values_display
|
changes_display_dict[verbose_name] = values_display
|
||||||
return changes_display_dict
|
return changes_display_dict
|
||||||
|
|
||||||
def _get_changes_display_for_fk_field(
|
def _get_changes_display_for_fk_field(
|
||||||
self, field: Union[models.ForeignKey, models.OneToOneField], value: Any
|
self, field: models.ForeignKey | models.OneToOneField, value: Any
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
:return: A string representing a given FK value and the field to which it belongs
|
:return: A string representing a given FK value and the field to which it belongs
|
||||||
|
|
@ -527,12 +564,19 @@ class LogEntry(models.Model):
|
||||||
return value
|
return value
|
||||||
# Attempt to return the string representation of the object
|
# Attempt to return the string representation of the object
|
||||||
try:
|
try:
|
||||||
return smart_str(field.related_model.objects.get(pk=pk_value))
|
related_model_manager = _get_manager_from_settings(field.related_model)
|
||||||
|
|
||||||
|
return smart_str(related_model_manager.get(pk=pk_value))
|
||||||
# ObjectDoesNotExist will be raised if the object was deleted.
|
# ObjectDoesNotExist will be raised if the object was deleted.
|
||||||
except ObjectDoesNotExist:
|
except ObjectDoesNotExist:
|
||||||
return f"Deleted '{field.related_model.__name__}' ({value})"
|
return f"Deleted '{field.related_model.__name__}' ({value})"
|
||||||
|
|
||||||
|
|
||||||
|
class LogEntry(AbstractLogEntry):
|
||||||
|
class Meta(AbstractLogEntry.Meta):
|
||||||
|
swappable = "AUDITLOG_LOGENTRY_MODEL"
|
||||||
|
|
||||||
|
|
||||||
class AuditlogHistoryField(GenericRelation):
|
class AuditlogHistoryField(GenericRelation):
|
||||||
"""
|
"""
|
||||||
A subclass of py:class:`django.contrib.contenttypes.fields.GenericRelation` that sets some default
|
A subclass of py:class:`django.contrib.contenttypes.fields.GenericRelation` that sets some default
|
||||||
|
|
@ -553,7 +597,7 @@ class AuditlogHistoryField(GenericRelation):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, pk_indexable=True, delete_related=False, **kwargs):
|
def __init__(self, pk_indexable=True, delete_related=False, **kwargs):
|
||||||
kwargs["to"] = LogEntry
|
kwargs["to"] = get_logentry_model()
|
||||||
|
|
||||||
if pk_indexable:
|
if pk_indexable:
|
||||||
kwargs["object_id_field"] = "object_id"
|
kwargs["object_id_field"] = "object_id"
|
||||||
|
|
@ -581,8 +625,8 @@ class AuditlogHistoryField(GenericRelation):
|
||||||
changes_func = None
|
changes_func = None
|
||||||
|
|
||||||
|
|
||||||
def _changes_func() -> Callable[[LogEntry], Dict]:
|
def _changes_func() -> Callable[[LogEntry], dict]:
|
||||||
def json_then_text(instance: LogEntry) -> Dict:
|
def json_then_text(instance: LogEntry) -> dict:
|
||||||
if instance.changes:
|
if instance.changes:
|
||||||
return instance.changes
|
return instance.changes
|
||||||
elif instance.changes_text:
|
elif instance.changes_text:
|
||||||
|
|
@ -590,9 +634,22 @@ def _changes_func() -> Callable[[LogEntry], Dict]:
|
||||||
return json.loads(instance.changes_text)
|
return json.loads(instance.changes_text)
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
def default(instance: LogEntry) -> Dict:
|
def default(instance: LogEntry) -> dict:
|
||||||
return instance.changes or {}
|
return instance.changes or {}
|
||||||
|
|
||||||
if settings.AUDITLOG_USE_TEXT_CHANGES_IF_JSON_IS_NOT_PRESENT:
|
if settings.AUDITLOG_USE_TEXT_CHANGES_IF_JSON_IS_NOT_PRESENT:
|
||||||
return json_then_text
|
return json_then_text
|
||||||
return default
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _get_manager_from_settings(model: type[models.Model]) -> models.Manager:
|
||||||
|
"""
|
||||||
|
Get model manager as selected by AUDITLOG_USE_BASE_MANAGER.
|
||||||
|
|
||||||
|
- True: return model._meta.base_manager
|
||||||
|
- False: return model._meta.default_manager
|
||||||
|
"""
|
||||||
|
if settings.AUDITLOG_USE_BASE_MANAGER:
|
||||||
|
return model._meta.base_manager
|
||||||
|
else:
|
||||||
|
return model._meta.default_manager
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,10 @@ from functools import wraps
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
|
from auditlog import get_logentry_model
|
||||||
from auditlog.context import auditlog_disabled
|
from auditlog.context import auditlog_disabled
|
||||||
from auditlog.diff import model_instance_diff
|
from auditlog.diff import model_instance_diff
|
||||||
from auditlog.models import LogEntry
|
from auditlog.models import _get_manager_from_settings
|
||||||
from auditlog.signals import post_log, pre_log
|
from auditlog.signals import post_log, pre_log
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -38,11 +39,12 @@ def log_create(sender, instance, created, **kwargs):
|
||||||
"""
|
"""
|
||||||
if created:
|
if created:
|
||||||
_create_log_entry(
|
_create_log_entry(
|
||||||
action=LogEntry.Action.CREATE,
|
action=get_logentry_model().Action.CREATE,
|
||||||
instance=instance,
|
instance=instance,
|
||||||
sender=sender,
|
sender=sender,
|
||||||
diff_old=None,
|
diff_old=None,
|
||||||
diff_new=instance,
|
diff_new=instance,
|
||||||
|
use_json_for_changes=settings.AUDITLOG_STORE_JSON_CHANGES,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -53,16 +55,17 @@ def log_update(sender, instance, **kwargs):
|
||||||
|
|
||||||
Direct use is discouraged, connect your model through :py:func:`auditlog.registry.register` instead.
|
Direct use is discouraged, connect your model through :py:func:`auditlog.registry.register` instead.
|
||||||
"""
|
"""
|
||||||
if not instance._state.adding:
|
if not instance._state.adding and instance.pk is not None:
|
||||||
update_fields = kwargs.get("update_fields", None)
|
update_fields = kwargs.get("update_fields", None)
|
||||||
old = sender.objects.filter(pk=instance.pk).first()
|
old = _get_manager_from_settings(sender).filter(pk=instance.pk).first()
|
||||||
_create_log_entry(
|
_create_log_entry(
|
||||||
action=LogEntry.Action.UPDATE,
|
action=get_logentry_model().Action.UPDATE,
|
||||||
instance=instance,
|
instance=instance,
|
||||||
sender=sender,
|
sender=sender,
|
||||||
diff_old=old,
|
diff_old=old,
|
||||||
diff_new=instance,
|
diff_new=instance,
|
||||||
fields_to_check=update_fields,
|
fields_to_check=update_fields,
|
||||||
|
use_json_for_changes=settings.AUDITLOG_STORE_JSON_CHANGES,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -75,11 +78,12 @@ def log_delete(sender, instance, **kwargs):
|
||||||
"""
|
"""
|
||||||
if instance.pk is not None:
|
if instance.pk is not None:
|
||||||
_create_log_entry(
|
_create_log_entry(
|
||||||
action=LogEntry.Action.DELETE,
|
action=get_logentry_model().Action.DELETE,
|
||||||
instance=instance,
|
instance=instance,
|
||||||
sender=sender,
|
sender=sender,
|
||||||
diff_old=instance,
|
diff_old=instance,
|
||||||
diff_new=None,
|
diff_new=None,
|
||||||
|
use_json_for_changes=settings.AUDITLOG_STORE_JSON_CHANGES,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -91,17 +95,25 @@ def log_access(sender, instance, **kwargs):
|
||||||
"""
|
"""
|
||||||
if instance.pk is not None:
|
if instance.pk is not None:
|
||||||
_create_log_entry(
|
_create_log_entry(
|
||||||
action=LogEntry.Action.ACCESS,
|
action=get_logentry_model().Action.ACCESS,
|
||||||
instance=instance,
|
instance=instance,
|
||||||
sender=sender,
|
sender=sender,
|
||||||
diff_old=None,
|
diff_old=None,
|
||||||
diff_new=None,
|
diff_new=None,
|
||||||
force_log=True,
|
force_log=True,
|
||||||
|
use_json_for_changes=settings.AUDITLOG_STORE_JSON_CHANGES,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _create_log_entry(
|
def _create_log_entry(
|
||||||
action, instance, sender, diff_old, diff_new, fields_to_check=None, force_log=False
|
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(
|
pre_log_results = pre_log.send(
|
||||||
sender,
|
sender,
|
||||||
|
|
@ -111,27 +123,30 @@ def _create_log_entry(
|
||||||
|
|
||||||
if any(item[1] is False for item in pre_log_results):
|
if any(item[1] is False for item in pre_log_results):
|
||||||
return
|
return
|
||||||
|
LogEntry = get_logentry_model()
|
||||||
|
|
||||||
error = None
|
error = None
|
||||||
log_created = False
|
log_entry = None
|
||||||
changes = None
|
changes = None
|
||||||
try:
|
try:
|
||||||
changes = model_instance_diff(
|
changes = model_instance_diff(
|
||||||
diff_old, diff_new, fields_to_check=fields_to_check
|
diff_old,
|
||||||
|
diff_new,
|
||||||
|
fields_to_check=fields_to_check,
|
||||||
|
use_json_for_changes=use_json_for_changes,
|
||||||
)
|
)
|
||||||
|
|
||||||
if force_log or changes:
|
if force_log or changes:
|
||||||
LogEntry.objects.log_create(
|
log_entry = LogEntry.objects.log_create(
|
||||||
instance,
|
instance,
|
||||||
action=action,
|
action=action,
|
||||||
changes=changes,
|
changes=changes,
|
||||||
force_log=force_log,
|
force_log=force_log,
|
||||||
)
|
)
|
||||||
log_created = True
|
|
||||||
except BaseException as e:
|
except BaseException as e:
|
||||||
error = e
|
error = e
|
||||||
finally:
|
finally:
|
||||||
if log_created or error:
|
if log_entry or error:
|
||||||
post_log.send(
|
post_log.send(
|
||||||
sender,
|
sender,
|
||||||
instance=instance,
|
instance=instance,
|
||||||
|
|
@ -140,7 +155,9 @@ def _create_log_entry(
|
||||||
error=error,
|
error=error,
|
||||||
pre_log_results=pre_log_results,
|
pre_log_results=pre_log_results,
|
||||||
changes=changes,
|
changes=changes,
|
||||||
log_created=log_created,
|
log_entry=log_entry,
|
||||||
|
log_created=log_entry is not None,
|
||||||
|
use_json_for_changes=settings.AUDITLOG_STORE_JSON_CHANGES,
|
||||||
)
|
)
|
||||||
if error:
|
if error:
|
||||||
raise error
|
raise error
|
||||||
|
|
@ -154,11 +171,14 @@ def make_log_m2m_changes(field_name):
|
||||||
"""Handle m2m_changed and call LogEntry.objects.log_m2m_changes as needed."""
|
"""Handle m2m_changed and call LogEntry.objects.log_m2m_changes as needed."""
|
||||||
if action not in ["post_add", "post_clear", "post_remove"]:
|
if action not in ["post_add", "post_clear", "post_remove"]:
|
||||||
return
|
return
|
||||||
|
LogEntry = get_logentry_model()
|
||||||
|
|
||||||
|
model_manager = _get_manager_from_settings(kwargs["model"])
|
||||||
|
|
||||||
if action == "post_clear":
|
if action == "post_clear":
|
||||||
changed_queryset = kwargs["model"].objects.all()
|
changed_queryset = model_manager.all()
|
||||||
else:
|
else:
|
||||||
changed_queryset = kwargs["model"].objects.filter(pk__in=kwargs["pk_set"])
|
changed_queryset = model_manager.filter(pk__in=kwargs["pk_set"])
|
||||||
|
|
||||||
if action in ["post_add"]:
|
if action in ["post_add"]:
|
||||||
LogEntry.objects.log_m2m_changes(
|
LogEntry.objects.log_m2m_changes(
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,7 @@
|
||||||
import copy
|
import copy
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from typing import (
|
from collections.abc import Callable, Collection, Iterable
|
||||||
Any,
|
from typing import Any
|
||||||
Callable,
|
|
||||||
Collection,
|
|
||||||
Dict,
|
|
||||||
Iterable,
|
|
||||||
List,
|
|
||||||
Optional,
|
|
||||||
Tuple,
|
|
||||||
Union,
|
|
||||||
)
|
|
||||||
|
|
||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
from django.db.models import ManyToManyField, Model
|
from django.db.models import ManyToManyField, Model
|
||||||
|
|
@ -26,7 +17,7 @@ from django.db.models.signals import (
|
||||||
from auditlog.conf import settings
|
from auditlog.conf import settings
|
||||||
from auditlog.signals import accessed
|
from auditlog.signals import accessed
|
||||||
|
|
||||||
DispatchUID = Tuple[int, int, int]
|
DispatchUID = tuple[int, int, int]
|
||||||
|
|
||||||
|
|
||||||
class AuditLogRegistrationError(Exception):
|
class AuditLogRegistrationError(Exception):
|
||||||
|
|
@ -38,7 +29,7 @@ class AuditlogModelRegistry:
|
||||||
A registry that keeps track of the models that use Auditlog to track changes.
|
A registry that keeps track of the models that use Auditlog to track changes.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
DEFAULT_EXCLUDE_MODELS = ("auditlog.LogEntry", "admin.LogEntry")
|
DEFAULT_EXCLUDE_MODELS = (settings.AUDITLOG_LOGENTRY_MODEL, "admin.LogEntry")
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
|
@ -47,7 +38,7 @@ class AuditlogModelRegistry:
|
||||||
delete: bool = True,
|
delete: bool = True,
|
||||||
access: bool = True,
|
access: bool = True,
|
||||||
m2m: bool = True,
|
m2m: bool = True,
|
||||||
custom: Optional[Dict[ModelSignal, Callable]] = None,
|
custom: dict[ModelSignal, Callable] | None = None,
|
||||||
):
|
):
|
||||||
from auditlog.receivers import log_access, log_create, log_delete, log_update
|
from auditlog.receivers import log_access, log_create, log_delete, log_update
|
||||||
|
|
||||||
|
|
@ -71,13 +62,14 @@ class AuditlogModelRegistry:
|
||||||
def register(
|
def register(
|
||||||
self,
|
self,
|
||||||
model: ModelBase = None,
|
model: ModelBase = None,
|
||||||
include_fields: Optional[List[str]] = None,
|
include_fields: list[str] | None = None,
|
||||||
exclude_fields: Optional[List[str]] = None,
|
exclude_fields: list[str] | None = None,
|
||||||
mapping_fields: Optional[Dict[str, str]] = None,
|
mapping_fields: dict[str, str] | None = None,
|
||||||
mask_fields: Optional[List[str]] = None,
|
mask_fields: list[str] | None = None,
|
||||||
m2m_fields: Optional[Collection[str]] = None,
|
mask_callable: str | None = None,
|
||||||
|
m2m_fields: Collection[str] | None = None,
|
||||||
serialize_data: bool = False,
|
serialize_data: bool = False,
|
||||||
serialize_kwargs: Optional[Dict[str, Any]] = None,
|
serialize_kwargs: dict[str, Any] | None = None,
|
||||||
serialize_auditlog_fields_only: bool = False,
|
serialize_auditlog_fields_only: bool = False,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
|
|
@ -88,6 +80,8 @@ class AuditlogModelRegistry:
|
||||||
:param exclude_fields: The fields to exclude. Overrides the fields to include.
|
:param exclude_fields: The fields to exclude. Overrides the fields to include.
|
||||||
:param mapping_fields: Mapping from field names to strings in diff.
|
:param mapping_fields: Mapping from field names to strings in diff.
|
||||||
:param mask_fields: The fields to mask for sensitive info.
|
: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 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_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_kwargs: Optional kwargs to pass to Django serializer
|
||||||
|
|
@ -116,6 +110,9 @@ class AuditlogModelRegistry:
|
||||||
for fld in settings.AUDITLOG_EXCLUDE_TRACKING_FIELDS:
|
for fld in settings.AUDITLOG_EXCLUDE_TRACKING_FIELDS:
|
||||||
exclude_fields.append(fld)
|
exclude_fields.append(fld)
|
||||||
|
|
||||||
|
for fld in settings.AUDITLOG_MASK_TRACKING_FIELDS:
|
||||||
|
mask_fields.append(fld)
|
||||||
|
|
||||||
def registrar(cls):
|
def registrar(cls):
|
||||||
"""Register models for a given class."""
|
"""Register models for a given class."""
|
||||||
if not issubclass(cls, Model):
|
if not issubclass(cls, Model):
|
||||||
|
|
@ -126,6 +123,7 @@ class AuditlogModelRegistry:
|
||||||
"exclude_fields": exclude_fields,
|
"exclude_fields": exclude_fields,
|
||||||
"mapping_fields": mapping_fields,
|
"mapping_fields": mapping_fields,
|
||||||
"mask_fields": mask_fields,
|
"mask_fields": mask_fields,
|
||||||
|
"mask_callable": mask_callable,
|
||||||
"m2m_fields": m2m_fields,
|
"m2m_fields": m2m_fields,
|
||||||
"serialize_data": serialize_data,
|
"serialize_data": serialize_data,
|
||||||
"serialize_kwargs": serialize_kwargs,
|
"serialize_kwargs": serialize_kwargs,
|
||||||
|
|
@ -169,7 +167,7 @@ class AuditlogModelRegistry:
|
||||||
else:
|
else:
|
||||||
self._disconnect_signals(model)
|
self._disconnect_signals(model)
|
||||||
|
|
||||||
def get_models(self) -> List[ModelBase]:
|
def get_models(self) -> list[ModelBase]:
|
||||||
return list(self._registry.keys())
|
return list(self._registry.keys())
|
||||||
|
|
||||||
def get_model_fields(self, model: ModelBase):
|
def get_model_fields(self, model: ModelBase):
|
||||||
|
|
@ -178,6 +176,7 @@ class AuditlogModelRegistry:
|
||||||
"exclude_fields": list(self._registry[model]["exclude_fields"]),
|
"exclude_fields": list(self._registry[model]["exclude_fields"]),
|
||||||
"mapping_fields": dict(self._registry[model]["mapping_fields"]),
|
"mapping_fields": dict(self._registry[model]["mapping_fields"]),
|
||||||
"mask_fields": list(self._registry[model]["mask_fields"]),
|
"mask_fields": list(self._registry[model]["mask_fields"]),
|
||||||
|
"mask_callable": self._registry[model]["mask_callable"],
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_serialize_options(self, model: ModelBase):
|
def get_serialize_options(self, model: ModelBase):
|
||||||
|
|
@ -211,7 +210,7 @@ class AuditlogModelRegistry:
|
||||||
m2m_changed.connect(
|
m2m_changed.connect(
|
||||||
receiver,
|
receiver,
|
||||||
sender=m2m_model,
|
sender=m2m_model,
|
||||||
dispatch_uid=self._dispatch_uid(m2m_changed, receiver),
|
dispatch_uid=self._m2m_dispatch_uid(m2m_changed, m2m_model),
|
||||||
)
|
)
|
||||||
|
|
||||||
def _disconnect_signals(self, model):
|
def _disconnect_signals(self, model):
|
||||||
|
|
@ -227,7 +226,7 @@ class AuditlogModelRegistry:
|
||||||
m2m_model = getattr(field, "through")
|
m2m_model = getattr(field, "through")
|
||||||
m2m_changed.disconnect(
|
m2m_changed.disconnect(
|
||||||
sender=m2m_model,
|
sender=m2m_model,
|
||||||
dispatch_uid=self._dispatch_uid(m2m_changed, receiver),
|
dispatch_uid=self._m2m_dispatch_uid(m2m_changed, m2m_model),
|
||||||
)
|
)
|
||||||
del self._m2m_signals[model]
|
del self._m2m_signals[model]
|
||||||
|
|
||||||
|
|
@ -235,7 +234,11 @@ class AuditlogModelRegistry:
|
||||||
"""Generate a dispatch_uid which is unique for a combination of self, signal, and receiver."""
|
"""Generate a dispatch_uid which is unique for a combination of self, signal, and receiver."""
|
||||||
return id(self), id(signal), id(receiver)
|
return id(self), id(signal), id(receiver)
|
||||||
|
|
||||||
def _get_model_classes(self, app_model: str) -> List[ModelBase]:
|
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:
|
||||||
try:
|
try:
|
||||||
app_label, model_name = app_model.split(".")
|
app_label, model_name = app_model.split(".")
|
||||||
|
|
@ -247,7 +250,7 @@ class AuditlogModelRegistry:
|
||||||
|
|
||||||
def _get_exclude_models(
|
def _get_exclude_models(
|
||||||
self, exclude_tracking_models: Iterable[str]
|
self, exclude_tracking_models: Iterable[str]
|
||||||
) -> List[ModelBase]:
|
) -> list[ModelBase]:
|
||||||
exclude_models = [
|
exclude_models = [
|
||||||
model
|
model
|
||||||
for app_model in tuple(exclude_tracking_models)
|
for app_model in tuple(exclude_tracking_models)
|
||||||
|
|
@ -256,7 +259,7 @@ class AuditlogModelRegistry:
|
||||||
]
|
]
|
||||||
return exclude_models
|
return exclude_models
|
||||||
|
|
||||||
def _register_models(self, models: Iterable[Union[str, Dict[str, Any]]]) -> None:
|
def _register_models(self, models: Iterable[str | dict[str, Any]]) -> None:
|
||||||
models = copy.deepcopy(models)
|
models = copy.deepcopy(models)
|
||||||
for model in models:
|
for model in models:
|
||||||
if isinstance(model, str):
|
if isinstance(model, str):
|
||||||
|
|
@ -305,6 +308,15 @@ class AuditlogModelRegistry:
|
||||||
"setting 'AUDITLOG_INCLUDE_ALL_MODELS' must be set to 'True'"
|
"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)):
|
if not isinstance(settings.AUDITLOG_INCLUDE_TRACKING_MODELS, (list, tuple)):
|
||||||
raise TypeError(
|
raise TypeError(
|
||||||
"Setting 'AUDITLOG_INCLUDE_TRACKING_MODELS' must be a list or tuple"
|
"Setting 'AUDITLOG_INCLUDE_TRACKING_MODELS' must be a list or tuple"
|
||||||
|
|
@ -315,6 +327,11 @@ class AuditlogModelRegistry:
|
||||||
"Setting 'AUDITLOG_EXCLUDE_TRACKING_FIELDS' must be a list or tuple"
|
"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:
|
for item in settings.AUDITLOG_INCLUDE_TRACKING_MODELS:
|
||||||
if not isinstance(item, (str, dict)):
|
if not isinstance(item, (str, dict)):
|
||||||
raise TypeError(
|
raise TypeError(
|
||||||
|
|
@ -359,6 +376,9 @@ class AuditlogModelRegistry:
|
||||||
model=model, m2m_fields=m2m_fields, exclude_fields=exclude_fields
|
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)
|
self._register_models(settings.AUDITLOG_INCLUDE_TRACKING_MODELS)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
95
auditlog/render.py
Normal file
95
auditlog/render.py
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
from django.core.exceptions import FieldDoesNotExist
|
||||||
|
from django.forms.utils import pretty_name
|
||||||
|
from django.utils.html import format_html, format_html_join
|
||||||
|
from django.utils.safestring import mark_safe
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
|
||||||
|
def render_logentry_changes_html(log_entry):
|
||||||
|
changes = log_entry.changes_dict
|
||||||
|
if not changes:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
atom_changes = {}
|
||||||
|
m2m_changes = {}
|
||||||
|
|
||||||
|
# Separate regular fields from M2M changes
|
||||||
|
for field, change in changes.items():
|
||||||
|
if isinstance(change, dict) and change.get("type") == "m2m":
|
||||||
|
m2m_changes[field] = change
|
||||||
|
else:
|
||||||
|
atom_changes[field] = change
|
||||||
|
|
||||||
|
html_parts = []
|
||||||
|
|
||||||
|
# Render regular field changes
|
||||||
|
if atom_changes:
|
||||||
|
html_parts.append(_render_field_changes(log_entry, atom_changes))
|
||||||
|
|
||||||
|
# Render M2M relationship changes
|
||||||
|
if m2m_changes:
|
||||||
|
html_parts.append(_render_m2m_changes(log_entry, m2m_changes))
|
||||||
|
|
||||||
|
return mark_safe("".join(html_parts))
|
||||||
|
|
||||||
|
|
||||||
|
def get_field_verbose_name(log_entry, field_name):
|
||||||
|
from auditlog.registry import auditlog
|
||||||
|
|
||||||
|
model = log_entry.content_type.model_class()
|
||||||
|
if model is None:
|
||||||
|
return field_name
|
||||||
|
|
||||||
|
# Try to get verbose name from auditlog mapping
|
||||||
|
try:
|
||||||
|
if auditlog.contains(model._meta.model):
|
||||||
|
model_fields = auditlog.get_model_fields(model._meta.model)
|
||||||
|
mapping_field_name = model_fields["mapping_fields"].get(field_name)
|
||||||
|
if mapping_field_name:
|
||||||
|
return mapping_field_name
|
||||||
|
except KeyError:
|
||||||
|
# Model definition in auditlog was probably removed
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Fall back to Django field verbose_name
|
||||||
|
try:
|
||||||
|
field = model._meta.get_field(field_name)
|
||||||
|
return pretty_name(getattr(field, "verbose_name", field_name))
|
||||||
|
except FieldDoesNotExist:
|
||||||
|
return pretty_name(field_name)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_field_changes(log_entry, atom_changes):
|
||||||
|
rows = []
|
||||||
|
rows.append(_format_header("#", _("Field"), _("From"), _("To")))
|
||||||
|
|
||||||
|
for i, (field, change) in enumerate(sorted(atom_changes.items()), 1):
|
||||||
|
field_name = get_field_verbose_name(log_entry, field)
|
||||||
|
values = ["***", "***"] if field == "password" else change
|
||||||
|
rows.append(_format_row(i, field_name, *values))
|
||||||
|
|
||||||
|
return f"<table>{''.join(rows)}</table>"
|
||||||
|
|
||||||
|
|
||||||
|
def _render_m2m_changes(log_entry, m2m_changes):
|
||||||
|
rows = []
|
||||||
|
rows.append(_format_header("#", _("Relationship"), _("Action"), _("Objects")))
|
||||||
|
|
||||||
|
for i, (field, change) in enumerate(sorted(m2m_changes.items()), 1):
|
||||||
|
field_name = get_field_verbose_name(log_entry, field)
|
||||||
|
objects_html = format_html_join(
|
||||||
|
mark_safe("<br>"),
|
||||||
|
"{}",
|
||||||
|
[(obj,) for obj in change["objects"]],
|
||||||
|
)
|
||||||
|
rows.append(_format_row(i, field_name, change["operation"], objects_html))
|
||||||
|
|
||||||
|
return f"<table>{''.join(rows)}</table>"
|
||||||
|
|
||||||
|
|
||||||
|
def _format_header(*labels):
|
||||||
|
return format_html("".join(["<tr>", "<th>{}</th>" * len(labels), "</tr>"]), *labels)
|
||||||
|
|
||||||
|
|
||||||
|
def _format_row(*values):
|
||||||
|
return format_html("".join(["<tr>", "<td>{}</td>" * len(values), "</tr>"]), *values)
|
||||||
|
|
@ -48,6 +48,10 @@ Keyword arguments sent with this signal:
|
||||||
this will be None. In some cases, such as when logging access to the instance,
|
this will be None. In some cases, such as when logging access to the instance,
|
||||||
the changes will be an empty dict.
|
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:
|
:param bool log_created:
|
||||||
Was the log actually created?
|
Was the log actually created?
|
||||||
This could be false if there was an error in creating the log.
|
This could be false if there was an error in creating the log.
|
||||||
|
|
|
||||||
18
auditlog/templates/auditlog/entry_detail.html
Normal file
18
auditlog/templates/auditlog/entry_detail.html
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
{% load i18n auditlog_tags %}
|
||||||
|
|
||||||
|
<div class="auditlog-entry">
|
||||||
|
<div class="entry-header">
|
||||||
|
<div class="entry-meta">
|
||||||
|
<span class="entry-timestamp">{{ entry.timestamp|date:"DATETIME_FORMAT" }}</span>
|
||||||
|
<span class="entry-user">{% if entry.actor %}{{ entry.actor }}{% else %}{% trans 'system' %}{% endif %}</span>
|
||||||
|
<span class="entry-action">{{ entry.get_action_display }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="entry-content">
|
||||||
|
{% if entry.action == entry.Action.DELETE or entry.action == entry.Action.ACCESS %}
|
||||||
|
<span class="no-changes">{% trans 'No field changes' %}</span>
|
||||||
|
{% else %}
|
||||||
|
{{ entry|render_logentry_changes_html|safe }}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
160
auditlog/templates/auditlog/object_history.html
Normal file
160
auditlog/templates/auditlog/object_history.html
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
{% extends "admin/base_site.html" %}
|
||||||
|
{% load i18n admin_urls static %}
|
||||||
|
|
||||||
|
{% block extrahead %}
|
||||||
|
{{ block.super }}
|
||||||
|
<style type="text/css">
|
||||||
|
.auditlog-entries {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 15px;
|
||||||
|
max-width: 1200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auditlog-entry {
|
||||||
|
border: 1px solid var(--hairline-color, #e1e1e1);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-header {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid var(--hairline-color, #e1e1e1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-timestamp {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-user {
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-action {
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 0.8em;
|
||||||
|
font-weight: 500;
|
||||||
|
border: 1px solid var(--hairline-color, #e1e1e1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-content {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-changes {
|
||||||
|
font-style: italic;
|
||||||
|
opacity: 0.7;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table styling */
|
||||||
|
.entry-content table {
|
||||||
|
width: auto;
|
||||||
|
min-width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 6px 0;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-content table th,
|
||||||
|
.entry-content table td {
|
||||||
|
padding: 6px 8px;
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
border: 1px solid var(--hairline-color, #e1e1e1);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-content table th {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-content table td {
|
||||||
|
max-width: 200px;
|
||||||
|
word-wrap: break-word;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-content table + table {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pagination styling */
|
||||||
|
.pagination {
|
||||||
|
margin-top: 16px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination a,
|
||||||
|
.pagination span {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 8px;
|
||||||
|
margin: 0 2px;
|
||||||
|
border: 1px solid var(--hairline-color, #e1e1e1);
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination .current {
|
||||||
|
font-weight: 600;
|
||||||
|
border-width: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.pagination-info {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 8px;
|
||||||
|
opacity: 0.7;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.auditlog-entries {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-content {
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block breadcrumbs %}
|
||||||
|
<div class="breadcrumbs">
|
||||||
|
<a href="{% url 'admin:index' %}">{% translate 'Home' %}</a>
|
||||||
|
› <a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ opts.app_config.verbose_name }}</a>
|
||||||
|
› <a href="{% url opts|admin_urlname:'changelist' %}">{{ module_name }}</a>
|
||||||
|
› <a href="{% url opts|admin_urlname:'change' object.pk|admin_urlquote %}">{{ object|truncatewords:'18' }}</a>
|
||||||
|
› {% 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 %}
|
||||||
16
auditlog/templates/auditlog/pagination.html
Normal file
16
auditlog/templates/auditlog/pagination.html
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
<div class="pagination">
|
||||||
|
{% for i in page_obj.paginator.page_range %}
|
||||||
|
{% if i == page_obj.paginator.ELLIPSIS %}
|
||||||
|
<span>...</span>
|
||||||
|
{% elif i == page_obj.number %}
|
||||||
|
<span class="current">{{ i }}</span>
|
||||||
|
{% else %}
|
||||||
|
<a href="?{{ page_var }}={{ i }}">{{ i }}</a>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<p class="pagination-info">
|
||||||
|
{{ page_obj.paginator.count }} {% blocktranslate count counter=page_obj.paginator.count %}entry{% plural %}entries{% endblocktranslate %}
|
||||||
|
</p>
|
||||||
16
auditlog/templatetags/auditlog_tags.py
Normal file
16
auditlog/templatetags/auditlog_tags.py
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
from django import template
|
||||||
|
|
||||||
|
from auditlog.render import render_logentry_changes_html as render_changes
|
||||||
|
|
||||||
|
register = template.Library()
|
||||||
|
|
||||||
|
|
||||||
|
@register.filter
|
||||||
|
def render_logentry_changes_html(log_entry):
|
||||||
|
"""
|
||||||
|
Format LogEntry changes as HTML.
|
||||||
|
|
||||||
|
Usage in template:
|
||||||
|
{{ log_entry_object|render_logentry_changes_html|safe }}
|
||||||
|
"""
|
||||||
|
return render_changes(log_entry)
|
||||||
0
auditlog_tests/auditlog
Normal file
0
auditlog_tests/auditlog
Normal file
0
auditlog_tests/custom_logentry_app/__init__.py
Normal file
0
auditlog_tests/custom_logentry_app/__init__.py
Normal file
5
auditlog_tests/custom_logentry_app/apps.py
Normal file
5
auditlog_tests/custom_logentry_app/apps.py
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class CustomLogEntryConfig(AppConfig):
|
||||||
|
name = "custom_logentry_app"
|
||||||
138
auditlog_tests/custom_logentry_app/migrations/0001_initial.py
Normal file
138
auditlog_tests/custom_logentry_app/migrations/0001_initial.py
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
# Generated by Django 4.2.25 on 2025-10-14 04:17
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import django.utils.timezone
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
("contenttypes", "0002_remove_content_type_name"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="CustomLogEntryModel",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.AutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"object_pk",
|
||||||
|
models.CharField(
|
||||||
|
db_index=True, max_length=255, verbose_name="object pk"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"object_id",
|
||||||
|
models.BigIntegerField(
|
||||||
|
blank=True, db_index=True, null=True, verbose_name="object id"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("object_repr", models.TextField(verbose_name="object representation")),
|
||||||
|
("serialized_data", models.JSONField(null=True)),
|
||||||
|
(
|
||||||
|
"action",
|
||||||
|
models.PositiveSmallIntegerField(
|
||||||
|
choices=[
|
||||||
|
(0, "create"),
|
||||||
|
(1, "update"),
|
||||||
|
(2, "delete"),
|
||||||
|
(3, "access"),
|
||||||
|
],
|
||||||
|
db_index=True,
|
||||||
|
verbose_name="action",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"changes_text",
|
||||||
|
models.TextField(blank=True, verbose_name="change message"),
|
||||||
|
),
|
||||||
|
("changes", models.JSONField(null=True, verbose_name="change message")),
|
||||||
|
(
|
||||||
|
"cid",
|
||||||
|
models.CharField(
|
||||||
|
blank=True,
|
||||||
|
db_index=True,
|
||||||
|
max_length=255,
|
||||||
|
null=True,
|
||||||
|
verbose_name="Correlation ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"remote_addr",
|
||||||
|
models.GenericIPAddressField(
|
||||||
|
blank=True, null=True, verbose_name="remote address"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"remote_port",
|
||||||
|
models.PositiveIntegerField(
|
||||||
|
blank=True, null=True, verbose_name="remote port"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"timestamp",
|
||||||
|
models.DateTimeField(
|
||||||
|
db_index=True,
|
||||||
|
default=django.utils.timezone.now,
|
||||||
|
verbose_name="timestamp",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"additional_data",
|
||||||
|
models.JSONField(
|
||||||
|
blank=True, null=True, verbose_name="additional data"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"actor_email",
|
||||||
|
models.CharField(
|
||||||
|
blank=True,
|
||||||
|
max_length=254,
|
||||||
|
null=True,
|
||||||
|
verbose_name="actor email",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("role", models.CharField(blank=True, max_length=100, null=True)),
|
||||||
|
(
|
||||||
|
"actor",
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="+",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
verbose_name="actor",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"content_type",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="+",
|
||||||
|
to="contenttypes.contenttype",
|
||||||
|
verbose_name="content type",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "log entry",
|
||||||
|
"verbose_name_plural": "log entries",
|
||||||
|
"ordering": ["-timestamp"],
|
||||||
|
"get_latest_by": "timestamp",
|
||||||
|
"abstract": False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
7
auditlog_tests/custom_logentry_app/models.py
Normal file
7
auditlog_tests/custom_logentry_app/models.py
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
from auditlog.models import AbstractLogEntry
|
||||||
|
|
||||||
|
|
||||||
|
class CustomLogEntryModel(AbstractLogEntry):
|
||||||
|
role = models.CharField(max_length=100, null=True, blank=True)
|
||||||
0
auditlog_tests/custom_logentry_app/urls.py
Normal file
0
auditlog_tests/custom_logentry_app/urls.py
Normal file
0
auditlog_tests/custom_logentry_app/views.py
Normal file
0
auditlog_tests/custom_logentry_app/views.py
Normal file
47
auditlog_tests/docker-compose.yml
Normal file
47
auditlog_tests/docker-compose.yml
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
container_name: auditlog_postgres
|
||||||
|
image: postgres:15
|
||||||
|
restart: "no"
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: auditlog
|
||||||
|
POSTGRES_USER: ${TEST_DB_USER}
|
||||||
|
POSTGRES_PASSWORD: ${TEST_DB_PASS}
|
||||||
|
ports:
|
||||||
|
- "${TEST_DB_PORT:-5432}:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres-data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: pg_isready -U ${TEST_DB_USER} -d auditlog
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
mysql:
|
||||||
|
container_name: auditlog_mysql
|
||||||
|
platform: linux/x86_64
|
||||||
|
image: mysql:8.4
|
||||||
|
restart: "no"
|
||||||
|
environment:
|
||||||
|
MYSQL_DATABASE: auditlog
|
||||||
|
MYSQL_USER: ${TEST_DB_USER}
|
||||||
|
MYSQL_PASSWORD: ${TEST_DB_PASS}
|
||||||
|
MYSQL_ROOT_PASSWORD: ${TEST_DB_PASS}
|
||||||
|
ports:
|
||||||
|
- "${TEST_DB_PORT:-3306}:3306"
|
||||||
|
expose:
|
||||||
|
- '${TEST_DB_PORT:-3306}'
|
||||||
|
volumes:
|
||||||
|
- mysql-data:/var/lib/mysql
|
||||||
|
- ./docker/db/init-mysql.sh:/docker-entrypoint-initdb.d/init.sh
|
||||||
|
healthcheck:
|
||||||
|
test: mysqladmin ping -h 127.0.0.1 -u ${TEST_DB_USER} --password=${TEST_DB_PASS}
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres-data:
|
||||||
|
driver: local
|
||||||
|
mysql-data:
|
||||||
|
driver: local
|
||||||
6
auditlog_tests/docker/db/init-mysql.sh
Executable file
6
auditlog_tests/docker/db/init-mysql.sh
Executable file
|
|
@ -0,0 +1,6 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
mysql -u root -p"$MYSQL_ROOT_PASSWORD" <<-EOSQL
|
||||||
|
GRANT ALL PRIVILEGES ON test_auditlog.* to '$MYSQL_USER';
|
||||||
|
EOSQL
|
||||||
|
|
@ -3,7 +3,7 @@ import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "auditlog_tests.test_settings")
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_settings")
|
||||||
|
|
||||||
from django.core.management import execute_from_command_line
|
from django.core.management import execute_from_command_line
|
||||||
|
|
||||||
|
|
|
||||||
12
auditlog_tests/middleware.py
Normal file
12
auditlog_tests/middleware.py
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
from auditlog.middleware import AuditlogMiddleware
|
||||||
|
|
||||||
|
|
||||||
|
class CustomAuditlogMiddleware(AuditlogMiddleware):
|
||||||
|
"""
|
||||||
|
Custom Middleware to couple the request's user role to log items.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get_extra_data(self, request):
|
||||||
|
context_data = super().get_extra_data(request)
|
||||||
|
context_data["role"] = "Role 1"
|
||||||
|
return context_data
|
||||||
0
auditlog_tests/test_app/__init__.py
Normal file
0
auditlog_tests/test_app/__init__.py
Normal file
|
|
@ -2,4 +2,4 @@ from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
class AuditlogTestConfig(AppConfig):
|
class AuditlogTestConfig(AppConfig):
|
||||||
name = "auditlog_tests"
|
name = "test_app"
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
"model": "auditlog_tests.manyrelatedmodel",
|
"model": "test_app.manyrelatedmodel",
|
||||||
"pk": 1,
|
"pk": 1,
|
||||||
"fields": {
|
"fields": {
|
||||||
"recursive": [1],
|
"recursive": [1],
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model": "auditlog_tests.manyrelatedothermodel",
|
"model": "test_app.manyrelatedothermodel",
|
||||||
"pk": 1,
|
"pk": 1,
|
||||||
"fields": {}
|
"fields": {}
|
||||||
}
|
}
|
||||||
6
auditlog_tests/test_app/mask.py
Normal file
6
auditlog_tests/test_app/mask.py
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
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
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from django.contrib.postgres.fields import ArrayField
|
from django.conf import settings
|
||||||
from django.core.serializers.json import DjangoJSONEncoder
|
from django.core.serializers.json import DjangoJSONEncoder
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
||||||
|
|
@ -20,11 +20,12 @@ class SimpleModel(models.Model):
|
||||||
boolean = models.BooleanField(default=False)
|
boolean = models.BooleanField(default=False)
|
||||||
integer = models.IntegerField(blank=True, null=True)
|
integer = models.IntegerField(blank=True, null=True)
|
||||||
datetime = models.DateTimeField(auto_now=True)
|
datetime = models.DateTimeField(auto_now=True)
|
||||||
|
char = models.CharField(null=True, max_length=100, default=lambda: "default value")
|
||||||
|
|
||||||
history = AuditlogHistoryField(delete_related=True)
|
history = AuditlogHistoryField(delete_related=True)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.text
|
return str(self.text)
|
||||||
|
|
||||||
|
|
||||||
class AltPrimaryKeyModel(models.Model):
|
class AltPrimaryKeyModel(models.Model):
|
||||||
|
|
@ -57,6 +58,26 @@ class UUIDPrimaryKeyModel(models.Model):
|
||||||
history = AuditlogHistoryField(delete_related=True, pk_indexable=False)
|
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):
|
class ProxyModel(SimpleModel):
|
||||||
"""
|
"""
|
||||||
A model that is a proxy for another model.
|
A model that is a proxy for another model.
|
||||||
|
|
@ -113,6 +134,61 @@ class ManyRelatedOtherModel(models.Model):
|
||||||
history = AuditlogHistoryField(delete_related=True)
|
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"])
|
@auditlog.register(include_fields=["label"])
|
||||||
class SimpleIncludeModel(models.Model):
|
class SimpleIncludeModel(models.Model):
|
||||||
"""
|
"""
|
||||||
|
|
@ -235,26 +311,36 @@ class CharfieldTextfieldModel(models.Model):
|
||||||
history = AuditlogHistoryField(delete_related=True)
|
history = AuditlogHistoryField(delete_related=True)
|
||||||
|
|
||||||
|
|
||||||
class PostgresArrayFieldModel(models.Model):
|
# Only define PostgreSQL-specific models when ArrayField is available
|
||||||
"""
|
if settings.TEST_DB_BACKEND == "postgresql":
|
||||||
Test auditlog with Postgres's ArrayField
|
from django.contrib.postgres.fields import ArrayField
|
||||||
"""
|
|
||||||
|
|
||||||
RED = "r"
|
class PostgresArrayFieldModel(models.Model):
|
||||||
YELLOW = "y"
|
"""
|
||||||
GREEN = "g"
|
Test auditlog with Postgres's ArrayField
|
||||||
|
"""
|
||||||
|
|
||||||
STATUS_CHOICES = (
|
RED = "r"
|
||||||
(RED, "Red"),
|
YELLOW = "y"
|
||||||
(YELLOW, "Yellow"),
|
GREEN = "g"
|
||||||
(GREEN, "Green"),
|
|
||||||
)
|
|
||||||
|
|
||||||
arrayfield = ArrayField(
|
STATUS_CHOICES = (
|
||||||
models.CharField(max_length=1, choices=STATUS_CHOICES), size=3
|
(RED, "Red"),
|
||||||
)
|
(YELLOW, "Yellow"),
|
||||||
|
(GREEN, "Green"),
|
||||||
|
)
|
||||||
|
|
||||||
history = AuditlogHistoryField(delete_related=True)
|
arrayfield = ArrayField(
|
||||||
|
models.CharField(max_length=1, choices=STATUS_CHOICES), size=3
|
||||||
|
)
|
||||||
|
|
||||||
|
history = AuditlogHistoryField(delete_related=True)
|
||||||
|
|
||||||
|
else:
|
||||||
|
|
||||||
|
class PostgresArrayFieldModel(models.Model):
|
||||||
|
class Meta:
|
||||||
|
managed = False
|
||||||
|
|
||||||
|
|
||||||
class NoDeleteHistoryModel(models.Model):
|
class NoDeleteHistoryModel(models.Model):
|
||||||
|
|
@ -269,6 +355,12 @@ class JSONModel(models.Model):
|
||||||
history = AuditlogHistoryField(delete_related=False)
|
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):
|
class SerializeThisModel(models.Model):
|
||||||
label = models.CharField(max_length=24, unique=True)
|
label = models.CharField(max_length=24, unique=True)
|
||||||
timestamp = models.DateTimeField()
|
timestamp = models.DateTimeField()
|
||||||
|
|
@ -326,26 +418,95 @@ class SimpleNonManagedModel(models.Model):
|
||||||
managed = False
|
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):
|
class AutoManyRelatedModel(models.Model):
|
||||||
related = models.ManyToManyField(SimpleModel)
|
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(AltPrimaryKeyModel)
|
||||||
auditlog.register(UUIDPrimaryKeyModel)
|
auditlog.register(UUIDPrimaryKeyModel)
|
||||||
|
auditlog.register(ModelPrimaryKeyModel)
|
||||||
auditlog.register(ProxyModel)
|
auditlog.register(ProxyModel)
|
||||||
|
auditlog.register(RelatedModelParent)
|
||||||
auditlog.register(RelatedModel)
|
auditlog.register(RelatedModel)
|
||||||
auditlog.register(ManyRelatedModel)
|
auditlog.register(ManyRelatedModel)
|
||||||
auditlog.register(ManyRelatedModel.recursive.through)
|
auditlog.register(ManyRelatedModel.recursive.through)
|
||||||
m2m_only_auditlog.register(ManyRelatedModel, m2m_fields={"related"})
|
m2m_only_auditlog.register(ManyRelatedModel, m2m_fields={"related"})
|
||||||
|
m2m_only_auditlog.register(ModelForReusableThroughModel, m2m_fields={"related"})
|
||||||
|
m2m_only_auditlog.register(OtherModelForReusableThroughModel, m2m_fields={"related"})
|
||||||
|
m2m_only_auditlog.register(SecretM2MModel, m2m_fields={"m2m_related"})
|
||||||
|
m2m_only_auditlog.register(SwappedManagerModel, m2m_fields={"m2m_related"})
|
||||||
auditlog.register(SimpleExcludeModel, exclude_fields=["text"])
|
auditlog.register(SimpleExcludeModel, exclude_fields=["text"])
|
||||||
auditlog.register(SimpleMappingModel, mapping_fields={"sku": "Product No."})
|
auditlog.register(SimpleMappingModel, mapping_fields={"sku": "Product No."})
|
||||||
auditlog.register(AdditionalDataIncludedModel)
|
auditlog.register(AdditionalDataIncludedModel)
|
||||||
auditlog.register(DateTimeFieldModel)
|
auditlog.register(DateTimeFieldModel)
|
||||||
auditlog.register(ChoicesFieldModel)
|
auditlog.register(ChoicesFieldModel)
|
||||||
auditlog.register(CharfieldTextfieldModel)
|
auditlog.register(CharfieldTextfieldModel)
|
||||||
auditlog.register(PostgresArrayFieldModel)
|
if settings.TEST_DB_BACKEND == "postgresql":
|
||||||
|
auditlog.register(PostgresArrayFieldModel)
|
||||||
auditlog.register(NoDeleteHistoryModel)
|
auditlog.register(NoDeleteHistoryModel)
|
||||||
auditlog.register(JSONModel)
|
auditlog.register(JSONModel)
|
||||||
|
auditlog.register(NullableJSONModel)
|
||||||
auditlog.register(
|
auditlog.register(
|
||||||
SerializeThisModel,
|
SerializeThisModel,
|
||||||
serialize_data=True,
|
serialize_data=True,
|
||||||
|
|
@ -363,3 +524,9 @@ auditlog.register(
|
||||||
serialize_data=True,
|
serialize_data=True,
|
||||||
serialize_kwargs={"use_natural_foreign_keys": 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)
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
from auditlog_tests.views import SimpleModelDetailview
|
from .views import SimpleModelDetailView
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("admin/", admin.site.urls),
|
path("admin/", admin.site.urls),
|
||||||
path(
|
path(
|
||||||
"simplemodel/<int:pk>/",
|
"simplemodel/<int:pk>/",
|
||||||
SimpleModelDetailview.as_view(),
|
SimpleModelDetailView.as_view(),
|
||||||
name="simplemodel-detail",
|
name="simplemodel-detail",
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
from django.views.generic import DetailView
|
from django.views.generic import DetailView
|
||||||
|
|
||||||
from auditlog.mixins import LogAccessMixin
|
from auditlog.mixins import LogAccessMixin
|
||||||
from auditlog_tests.models import SimpleModel
|
|
||||||
|
from .models import SimpleModel
|
||||||
|
|
||||||
|
|
||||||
class SimpleModelDetailview(LogAccessMixin, DetailView):
|
class SimpleModelDetailView(LogAccessMixin, DetailView):
|
||||||
model = SimpleModel
|
model = SimpleModel
|
||||||
template_name = "simplemodel_detail.html"
|
template_name = "simplemodel_detail.html"
|
||||||
|
|
@ -6,9 +6,12 @@ from unittest import mock
|
||||||
|
|
||||||
import freezegun
|
import freezegun
|
||||||
from django.core.management import call_command
|
from django.core.management import call_command
|
||||||
from django.test import TestCase
|
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_tests.models import SimpleModel
|
from auditlog.management.commands.auditlogflush import TruncateQuery
|
||||||
|
|
||||||
|
|
||||||
class AuditlogFlushTest(TestCase):
|
class AuditlogFlushTest(TestCase):
|
||||||
|
|
@ -110,3 +113,93 @@ class AuditlogFlushTest(TestCase):
|
||||||
out, "Deleted 1 objects.", msg="Output shows deleted 1 object."
|
out, "Deleted 1 objects.", msg="Output shows deleted 1 object."
|
||||||
)
|
)
|
||||||
self.assertEqual(err, "", msg="No stderr")
|
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")
|
||||||
|
|
|
||||||
51
auditlog_tests/test_postgresql.py
Normal file
51
auditlog_tests/test_postgresql.py
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
"""
|
||||||
|
PostgreSQL-specific tests for django-auditlog.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from unittest import skipIf
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.test import TestCase
|
||||||
|
from test_app.models import PostgresArrayFieldModel
|
||||||
|
|
||||||
|
|
||||||
|
@skipIf(settings.TEST_DB_BACKEND != "postgresql", "PostgreSQL-specific test")
|
||||||
|
class PostgresArrayFieldModelTest(TestCase):
|
||||||
|
databases = "__all__"
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.obj = PostgresArrayFieldModel.objects.create(
|
||||||
|
arrayfield=[PostgresArrayFieldModel.RED, PostgresArrayFieldModel.GREEN],
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def latest_array_change(self):
|
||||||
|
return self.obj.history.latest().changes_display_dict["arrayfield"][1]
|
||||||
|
|
||||||
|
def test_changes_display_dict_arrayfield(self):
|
||||||
|
self.assertEqual(
|
||||||
|
self.latest_array_change,
|
||||||
|
"Red, Green",
|
||||||
|
msg="The human readable text for the two choices, 'Red, Green' is displayed.",
|
||||||
|
)
|
||||||
|
self.obj.arrayfield = [PostgresArrayFieldModel.GREEN]
|
||||||
|
self.obj.save()
|
||||||
|
self.assertEqual(
|
||||||
|
self.latest_array_change,
|
||||||
|
"Green",
|
||||||
|
msg="The human readable text 'Green' is displayed.",
|
||||||
|
)
|
||||||
|
self.obj.arrayfield = []
|
||||||
|
self.obj.save()
|
||||||
|
self.assertEqual(
|
||||||
|
self.latest_array_change,
|
||||||
|
"",
|
||||||
|
msg="The human readable text '' is displayed.",
|
||||||
|
)
|
||||||
|
self.obj.arrayfield = [PostgresArrayFieldModel.GREEN]
|
||||||
|
self.obj.save()
|
||||||
|
self.assertEqual(
|
||||||
|
self.latest_array_change,
|
||||||
|
"Green",
|
||||||
|
msg="The human readable text 'Green' is displayed.",
|
||||||
|
)
|
||||||
167
auditlog_tests/test_render.py
Normal file
167
auditlog_tests/test_render.py
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
from django.test import TestCase
|
||||||
|
from test_app.models import SimpleModel
|
||||||
|
|
||||||
|
from auditlog import get_logentry_model
|
||||||
|
from auditlog.templatetags.auditlog_tags import render_logentry_changes_html
|
||||||
|
|
||||||
|
LogEntry = get_logentry_model()
|
||||||
|
|
||||||
|
|
||||||
|
class RenderChangesTest(TestCase):
|
||||||
|
|
||||||
|
def _create_log_entry(self, action, changes):
|
||||||
|
return LogEntry.objects.log_create(
|
||||||
|
SimpleModel.objects.create(),
|
||||||
|
action=action,
|
||||||
|
changes=changes,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_render_changes_empty(self):
|
||||||
|
log_entry = self._create_log_entry(LogEntry.Action.CREATE, {})
|
||||||
|
result = render_logentry_changes_html(log_entry)
|
||||||
|
self.assertEqual(result, "")
|
||||||
|
|
||||||
|
def test_render_changes_simple_field(self):
|
||||||
|
changes = {"text": ["old text", "new text"]}
|
||||||
|
log_entry = self._create_log_entry(LogEntry.Action.UPDATE, changes)
|
||||||
|
|
||||||
|
result = render_logentry_changes_html(log_entry)
|
||||||
|
|
||||||
|
self.assertIn("<table>", result)
|
||||||
|
self.assertIn("<th>#</th>", result)
|
||||||
|
self.assertIn("<th>Field</th>", result)
|
||||||
|
self.assertIn("<th>From</th>", result)
|
||||||
|
self.assertIn("<th>To</th>", result)
|
||||||
|
self.assertIn("old text", result)
|
||||||
|
self.assertIn("new text", result)
|
||||||
|
self.assertIsInstance(result, str)
|
||||||
|
|
||||||
|
def test_render_changes_password_field(self):
|
||||||
|
changes = {"password": ["oldpass", "newpass"]}
|
||||||
|
log_entry = self._create_log_entry(LogEntry.Action.UPDATE, changes)
|
||||||
|
|
||||||
|
result = render_logentry_changes_html(log_entry)
|
||||||
|
|
||||||
|
self.assertIn("***", result)
|
||||||
|
self.assertNotIn("oldpass", result)
|
||||||
|
self.assertNotIn("newpass", result)
|
||||||
|
|
||||||
|
def test_render_changes_m2m_field(self):
|
||||||
|
changes = {
|
||||||
|
"related_objects": {
|
||||||
|
"type": "m2m",
|
||||||
|
"operation": "add",
|
||||||
|
"objects": ["obj1", "obj2", "obj3"],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log_entry = self._create_log_entry(LogEntry.Action.UPDATE, changes)
|
||||||
|
|
||||||
|
result = render_logentry_changes_html(log_entry)
|
||||||
|
|
||||||
|
self.assertIn("<table>", result)
|
||||||
|
self.assertIn("<th>#</th>", result)
|
||||||
|
self.assertIn("<th>Relationship</th>", result)
|
||||||
|
self.assertIn("<th>Action</th>", result)
|
||||||
|
self.assertIn("<th>Objects</th>", result)
|
||||||
|
self.assertIn("add", result)
|
||||||
|
self.assertIn("obj1", result)
|
||||||
|
self.assertIn("obj2", result)
|
||||||
|
self.assertIn("obj3", result)
|
||||||
|
|
||||||
|
def test_render_changes_mixed_fields(self):
|
||||||
|
changes = {
|
||||||
|
"text": ["old text", "new text"],
|
||||||
|
"related_objects": {
|
||||||
|
"type": "m2m",
|
||||||
|
"operation": "remove",
|
||||||
|
"objects": ["obj1"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
log_entry = self._create_log_entry(LogEntry.Action.UPDATE, changes)
|
||||||
|
|
||||||
|
result = render_logentry_changes_html(log_entry)
|
||||||
|
|
||||||
|
tables = result.count("<table>")
|
||||||
|
self.assertEqual(tables, 2)
|
||||||
|
|
||||||
|
self.assertIn("old text", result)
|
||||||
|
self.assertIn("new text", result)
|
||||||
|
|
||||||
|
self.assertIn("remove", result)
|
||||||
|
self.assertIn("obj1", result)
|
||||||
|
|
||||||
|
def test_render_changes_field_verbose_name(self):
|
||||||
|
changes = {"text": ["old", "new"]}
|
||||||
|
log_entry = self._create_log_entry(LogEntry.Action.UPDATE, changes)
|
||||||
|
|
||||||
|
result = render_logentry_changes_html(log_entry)
|
||||||
|
|
||||||
|
self.assertIn("Text", result)
|
||||||
|
|
||||||
|
def test_render_changes_with_none_values(self):
|
||||||
|
changes = {"text": [None, "new text"], "boolean": [True, None]}
|
||||||
|
log_entry = self._create_log_entry(LogEntry.Action.UPDATE, changes)
|
||||||
|
|
||||||
|
result = render_logentry_changes_html(log_entry)
|
||||||
|
|
||||||
|
self.assertIn("None", result)
|
||||||
|
self.assertIn("new text", result)
|
||||||
|
self.assertIn("True", result)
|
||||||
|
|
||||||
|
def test_render_changes_sorted_fields(self):
|
||||||
|
changes = {
|
||||||
|
"z_field": ["old", "new"],
|
||||||
|
"a_field": ["old", "new"],
|
||||||
|
"m_field": ["old", "new"],
|
||||||
|
}
|
||||||
|
log_entry = self._create_log_entry(LogEntry.Action.UPDATE, changes)
|
||||||
|
|
||||||
|
result = render_logentry_changes_html(log_entry)
|
||||||
|
|
||||||
|
a_index = result.find("A field")
|
||||||
|
m_index = result.find("M field")
|
||||||
|
z_index = result.find("Z field")
|
||||||
|
|
||||||
|
self.assertLess(a_index, m_index)
|
||||||
|
self.assertLess(m_index, z_index)
|
||||||
|
|
||||||
|
def test_render_changes_m2m_sorted_fields(self):
|
||||||
|
changes = {
|
||||||
|
"z_related": {"type": "m2m", "operation": "add", "objects": ["obj1"]},
|
||||||
|
"a_related": {"type": "m2m", "operation": "remove", "objects": ["obj2"]},
|
||||||
|
}
|
||||||
|
log_entry = self._create_log_entry(LogEntry.Action.UPDATE, changes)
|
||||||
|
|
||||||
|
result = render_logentry_changes_html(log_entry)
|
||||||
|
|
||||||
|
a_index = result.find("A related")
|
||||||
|
z_index = result.find("Z related")
|
||||||
|
|
||||||
|
self.assertLess(a_index, z_index)
|
||||||
|
|
||||||
|
def test_render_changes_create_action(self):
|
||||||
|
changes = {
|
||||||
|
"text": [None, "new value"],
|
||||||
|
"boolean": [None, True],
|
||||||
|
}
|
||||||
|
log_entry = self._create_log_entry(LogEntry.Action.CREATE, changes)
|
||||||
|
|
||||||
|
result = render_logentry_changes_html(log_entry)
|
||||||
|
|
||||||
|
self.assertIn("<table>", result)
|
||||||
|
self.assertIn("new value", result)
|
||||||
|
self.assertIn("True", result)
|
||||||
|
|
||||||
|
def test_render_changes_delete_action(self):
|
||||||
|
changes = {
|
||||||
|
"text": ["old value", None],
|
||||||
|
"boolean": [True, None],
|
||||||
|
}
|
||||||
|
log_entry = self._create_log_entry(LogEntry.Action.DELETE, changes)
|
||||||
|
|
||||||
|
result = render_logentry_changes_html(log_entry)
|
||||||
|
|
||||||
|
self.assertIn("<table>", result)
|
||||||
|
self.assertIn("old value", result)
|
||||||
|
self.assertIn("True", result)
|
||||||
|
self.assertIn("None", result)
|
||||||
|
|
@ -1,12 +1,15 @@
|
||||||
"""
|
"""
|
||||||
Settings file for the Auditlog test suite.
|
Settings file for the Auditlog test suite.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
DEBUG = True
|
DEBUG = True
|
||||||
|
|
||||||
SECRET_KEY = "test"
|
SECRET_KEY = "test"
|
||||||
|
|
||||||
|
TEST_DB_BACKEND = os.getenv("TEST_DB_BACKEND", "sqlite3")
|
||||||
|
|
||||||
INSTALLED_APPS = [
|
INSTALLED_APPS = [
|
||||||
"django.contrib.auth",
|
"django.contrib.auth",
|
||||||
"django.contrib.contenttypes",
|
"django.contrib.contenttypes",
|
||||||
|
|
@ -14,30 +17,72 @@ INSTALLED_APPS = [
|
||||||
"django.contrib.sessions",
|
"django.contrib.sessions",
|
||||||
"django.contrib.admin",
|
"django.contrib.admin",
|
||||||
"django.contrib.staticfiles",
|
"django.contrib.staticfiles",
|
||||||
|
"django.contrib.postgres",
|
||||||
|
"custom_logentry_app",
|
||||||
"auditlog",
|
"auditlog",
|
||||||
"auditlog_tests",
|
"test_app",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
"django.middleware.common.CommonMiddleware",
|
"django.middleware.common.CommonMiddleware",
|
||||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||||
"django.contrib.messages.middleware.MessageMiddleware",
|
"django.contrib.messages.middleware.MessageMiddleware",
|
||||||
"auditlog.middleware.AuditlogMiddleware",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
DATABASES = {
|
if os.environ.get("AUDITLOG_LOGENTRY_MODEL", None):
|
||||||
"default": {
|
MIDDLEWARE = MIDDLEWARE + ["auditlog.middleware.AuditlogMiddleware"]
|
||||||
"ENGINE": "django.db.backends.postgresql",
|
else:
|
||||||
"NAME": os.getenv(
|
MIDDLEWARE = MIDDLEWARE + ["middleware.CustomAuditlogMiddleware"]
|
||||||
"TEST_DB_NAME", "auditlog" + os.environ.get("TOX_PARALLEL_ENV", "")
|
|
||||||
),
|
|
||||||
"USER": os.getenv("TEST_DB_USER", "postgres"),
|
if TEST_DB_BACKEND == "postgresql":
|
||||||
"PASSWORD": os.getenv("TEST_DB_PASS", ""),
|
DATABASES = {
|
||||||
"HOST": os.getenv("TEST_DB_HOST", "127.0.0.1"),
|
"default": {
|
||||||
"PORT": os.getenv("TEST_DB_PORT", "5432"),
|
"ENGINE": "django.db.backends.postgresql",
|
||||||
|
"NAME": os.getenv(
|
||||||
|
"TEST_DB_NAME", "auditlog" + os.environ.get("TOX_PARALLEL_ENV", "")
|
||||||
|
),
|
||||||
|
"USER": os.getenv("TEST_DB_USER", "postgres"),
|
||||||
|
"PASSWORD": os.getenv("TEST_DB_PASS", ""),
|
||||||
|
"HOST": os.getenv("TEST_DB_HOST", "127.0.0.1"),
|
||||||
|
"PORT": os.getenv("TEST_DB_PORT", "5432"),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
elif TEST_DB_BACKEND == "mysql":
|
||||||
|
DATABASES = {
|
||||||
|
"default": {
|
||||||
|
"ENGINE": "django.db.backends.mysql",
|
||||||
|
"NAME": os.getenv(
|
||||||
|
"TEST_DB_NAME", "auditlog" + os.environ.get("TOX_PARALLEL_ENV", "")
|
||||||
|
),
|
||||||
|
"USER": os.getenv("TEST_DB_USER", "root"),
|
||||||
|
"PASSWORD": os.getenv("TEST_DB_PASS", ""),
|
||||||
|
"HOST": os.getenv("TEST_DB_HOST", "127.0.0.1"),
|
||||||
|
"PORT": os.getenv("TEST_DB_PORT", "3306"),
|
||||||
|
"OPTIONS": {
|
||||||
|
"charset": "utf8mb4",
|
||||||
|
"init_command": "SET sql_mode='STRICT_TRANS_TABLES'",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
elif TEST_DB_BACKEND == "sqlite3":
|
||||||
|
DATABASES = {
|
||||||
|
"default": {
|
||||||
|
"ENGINE": "django.db.backends.sqlite3",
|
||||||
|
"NAME": os.getenv(
|
||||||
|
"TEST_DB_NAME",
|
||||||
|
(
|
||||||
|
":memory:"
|
||||||
|
if os.getenv("TOX_PARALLEL_ENV")
|
||||||
|
else "test_auditlog.sqlite3"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unsupported database backend: {TEST_DB_BACKEND}")
|
||||||
|
|
||||||
TEMPLATES = [
|
TEMPLATES = [
|
||||||
{
|
{
|
||||||
|
|
@ -56,8 +101,10 @@ TEMPLATES = [
|
||||||
|
|
||||||
STATIC_URL = "/static/"
|
STATIC_URL = "/static/"
|
||||||
|
|
||||||
ROOT_URLCONF = "auditlog_tests.urls"
|
ROOT_URLCONF = "test_app.urls"
|
||||||
|
|
||||||
USE_TZ = True
|
USE_TZ = True
|
||||||
|
|
||||||
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
|
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
|
||||||
|
|
||||||
|
AUDITLOG_LOGENTRY_MODEL = os.environ.get("AUDITLOG_LOGENTRY_MODEL", "auditlog.LogEntry")
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,15 @@ import json
|
||||||
from io import StringIO
|
from io import StringIO
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
from django.core.management import CommandError, call_command
|
from django.core.management import CommandError, call_command
|
||||||
from django.test import TestCase, override_settings
|
from django.test import TestCase, override_settings
|
||||||
|
from django.test.utils import skipIf
|
||||||
|
from test_app.models import SimpleModel
|
||||||
|
|
||||||
from auditlog.models import LogEntry
|
from auditlog import get_logentry_model
|
||||||
from auditlog_tests.models import SimpleModel
|
|
||||||
|
LogEntry = get_logentry_model()
|
||||||
|
|
||||||
|
|
||||||
class TwoStepMigrationTest(TestCase):
|
class TwoStepMigrationTest(TestCase):
|
||||||
|
|
@ -44,6 +48,7 @@ class AuditlogMigrateJsonTest(TestCase):
|
||||||
def call_command(self, *args, **kwargs):
|
def call_command(self, *args, **kwargs):
|
||||||
outbuf = StringIO()
|
outbuf = StringIO()
|
||||||
errbuf = StringIO()
|
errbuf = StringIO()
|
||||||
|
args = ("--no-color",) + args
|
||||||
call_command(
|
call_command(
|
||||||
"auditlogmigratejson", *args, stdout=outbuf, stderr=errbuf, **kwargs
|
"auditlogmigratejson", *args, stdout=outbuf, stderr=errbuf, **kwargs
|
||||||
)
|
)
|
||||||
|
|
@ -116,13 +121,17 @@ class AuditlogMigrateJsonTest(TestCase):
|
||||||
self.make_logentry()
|
self.make_logentry()
|
||||||
|
|
||||||
# Act
|
# Act
|
||||||
with patch("auditlog.models.LogEntry.objects.bulk_update") as bulk_update:
|
LogEntry = get_logentry_model()
|
||||||
|
path = f"{LogEntry.__module__}.{LogEntry.__name__}.objects.bulk_update"
|
||||||
|
|
||||||
|
with patch(path) as bulk_update:
|
||||||
outbuf, errbuf = self.call_command("-b=1")
|
outbuf, errbuf = self.call_command("-b=1")
|
||||||
call_count = bulk_update.call_count
|
call_count = bulk_update.call_count
|
||||||
|
|
||||||
# Assert
|
# Assert
|
||||||
self.assertEqual(call_count, 2)
|
self.assertEqual(call_count, 2)
|
||||||
|
|
||||||
|
@skipIf(settings.TEST_DB_BACKEND != "postgresql", "PostgreSQL-specific test")
|
||||||
def test_native_postgres(self):
|
def test_native_postgres(self):
|
||||||
# Arrange
|
# Arrange
|
||||||
log_entry = self.make_logentry()
|
log_entry = self.make_logentry()
|
||||||
|
|
@ -135,6 +144,22 @@ class AuditlogMigrateJsonTest(TestCase):
|
||||||
self.assertEqual(errbuf, "")
|
self.assertEqual(errbuf, "")
|
||||||
self.assertIsNotNone(log_entry.changes)
|
self.assertIsNotNone(log_entry.changes)
|
||||||
|
|
||||||
|
@skipIf(settings.TEST_DB_BACKEND != "postgresql", "PostgreSQL-specific test")
|
||||||
|
def test_native_postgres_changes_not_overwritten(self):
|
||||||
|
# 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):
|
def test_native_unsupported(self):
|
||||||
# Arrange
|
# Arrange
|
||||||
log_entry = self.make_logentry()
|
log_entry = self.make_logentry()
|
||||||
|
|
|
||||||
198
auditlog_tests/test_use_json_for_changes.py
Normal file
198
auditlog_tests/test_use_json_for_changes.py
Normal file
|
|
@ -0,0 +1,198 @@
|
||||||
|
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"])
|
||||||
175
auditlog_tests/test_view.py
Normal file
175
auditlog_tests/test_view.py
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from django.contrib import admin
|
||||||
|
from django.contrib.admin.sites import AdminSite
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.test import RequestFactory, TestCase
|
||||||
|
from test_app.models import SimpleModel
|
||||||
|
|
||||||
|
from auditlog.mixins import AuditlogHistoryAdminMixin
|
||||||
|
|
||||||
|
|
||||||
|
class TestModelAdmin(AuditlogHistoryAdminMixin, admin.ModelAdmin):
|
||||||
|
model = SimpleModel
|
||||||
|
auditlog_history_per_page = 5
|
||||||
|
|
||||||
|
|
||||||
|
class TestAuditlogHistoryAdminMixin(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = get_user_model().objects.create_user(
|
||||||
|
username="test_admin", is_staff=True, is_superuser=True, is_active=True
|
||||||
|
)
|
||||||
|
self.site = AdminSite()
|
||||||
|
|
||||||
|
self.admin = TestModelAdmin(SimpleModel, self.site)
|
||||||
|
|
||||||
|
self.obj = SimpleModel.objects.create(text="Test object")
|
||||||
|
|
||||||
|
def test_auditlog_history_view_requires_permission(self):
|
||||||
|
request = RequestFactory().get("/")
|
||||||
|
request.user = get_user_model().objects.create_user(
|
||||||
|
username="non_staff_user", password="testpass"
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaises(Exception):
|
||||||
|
self.admin.auditlog_history_view(request, str(self.obj.pk))
|
||||||
|
|
||||||
|
def test_auditlog_history_view_with_permission(self):
|
||||||
|
request = RequestFactory().get("/")
|
||||||
|
request.user = self.user
|
||||||
|
|
||||||
|
response = self.admin.auditlog_history_view(request, str(self.obj.pk))
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIn("log_entries", response.context_data)
|
||||||
|
self.assertIn("object", response.context_data)
|
||||||
|
self.assertEqual(response.context_data["object"], self.obj)
|
||||||
|
|
||||||
|
def test_auditlog_history_view_pagination(self):
|
||||||
|
"""Test that pagination works correctly."""
|
||||||
|
for i in range(10):
|
||||||
|
self.obj.text = f"Updated text {i}"
|
||||||
|
self.obj.save()
|
||||||
|
|
||||||
|
request = RequestFactory().get("/")
|
||||||
|
request.user = self.user
|
||||||
|
|
||||||
|
response = self.admin.auditlog_history_view(request, str(self.obj.pk))
|
||||||
|
|
||||||
|
self.assertTrue(response.context_data["pagination_required"])
|
||||||
|
self.assertEqual(len(response.context_data["log_entries"]), 5)
|
||||||
|
|
||||||
|
def test_auditlog_history_view_page_parameter(self):
|
||||||
|
# Create more log entries by updating the object
|
||||||
|
for i in range(10):
|
||||||
|
self.obj.text = f"Updated text {i}"
|
||||||
|
self.obj.save()
|
||||||
|
|
||||||
|
request = RequestFactory().get("/?p=2")
|
||||||
|
request.user = self.user
|
||||||
|
|
||||||
|
response = self.admin.auditlog_history_view(request, str(self.obj.pk))
|
||||||
|
|
||||||
|
# Should be on page 2
|
||||||
|
self.assertEqual(response.context_data["log_entries"].number, 2)
|
||||||
|
|
||||||
|
def test_auditlog_history_view_context_data(self):
|
||||||
|
request = RequestFactory().get("/")
|
||||||
|
request.user = self.user
|
||||||
|
|
||||||
|
response = self.admin.auditlog_history_view(request, str(self.obj.pk))
|
||||||
|
|
||||||
|
context = response.context_data
|
||||||
|
required_keys = [
|
||||||
|
"title",
|
||||||
|
"module_name",
|
||||||
|
"page_range",
|
||||||
|
"page_var",
|
||||||
|
"pagination_required",
|
||||||
|
"object",
|
||||||
|
"opts",
|
||||||
|
"log_entries",
|
||||||
|
]
|
||||||
|
|
||||||
|
for key in required_keys:
|
||||||
|
self.assertIn(key, context)
|
||||||
|
|
||||||
|
self.assertIn(str(self.obj), context["title"])
|
||||||
|
self.assertEqual(context["object"], self.obj)
|
||||||
|
self.assertEqual(context["opts"], self.obj._meta)
|
||||||
|
|
||||||
|
def test_auditlog_history_view_extra_context(self):
|
||||||
|
request = RequestFactory().get("/")
|
||||||
|
request.user = self.user
|
||||||
|
|
||||||
|
extra_context = {"extra_key": "extra_value"}
|
||||||
|
response = self.admin.auditlog_history_view(
|
||||||
|
request, str(self.obj.pk), extra_context
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn("extra_key", response.context_data)
|
||||||
|
self.assertEqual(response.context_data["extra_key"], "extra_value")
|
||||||
|
|
||||||
|
def test_auditlog_history_view_template(self):
|
||||||
|
request = RequestFactory().get("/")
|
||||||
|
request.user = self.user
|
||||||
|
|
||||||
|
response = self.admin.auditlog_history_view(request, str(self.obj.pk))
|
||||||
|
|
||||||
|
self.assertEqual(response.template_name, self.admin.auditlog_history_template)
|
||||||
|
|
||||||
|
def test_auditlog_history_view_log_entries_ordering(self):
|
||||||
|
self.obj.text = "First update"
|
||||||
|
self.obj.save()
|
||||||
|
self.obj.text = "Second update"
|
||||||
|
self.obj.save()
|
||||||
|
|
||||||
|
request = RequestFactory().get("/")
|
||||||
|
request.user = self.user
|
||||||
|
|
||||||
|
response = self.admin.auditlog_history_view(request, str(self.obj.pk))
|
||||||
|
|
||||||
|
log_entries = list(response.context_data["log_entries"])
|
||||||
|
self.assertGreaterEqual(log_entries[0].timestamp, log_entries[1].timestamp)
|
||||||
|
|
||||||
|
def test_get_list_display_with_auditlog_link(self):
|
||||||
|
self.admin.show_auditlog_history_link = True
|
||||||
|
list_display = self.admin.get_list_display(RequestFactory().get("/"))
|
||||||
|
|
||||||
|
self.assertIn("auditlog_link", list_display)
|
||||||
|
|
||||||
|
self.admin.show_auditlog_history_link = False
|
||||||
|
list_display = self.admin.get_list_display(RequestFactory().get("/"))
|
||||||
|
|
||||||
|
self.assertNotIn("auditlog_link", list_display)
|
||||||
|
|
||||||
|
def test_get_urls_includes_auditlog_url(self):
|
||||||
|
urls = self.admin.get_urls()
|
||||||
|
|
||||||
|
self.assertGreater(len(urls), 0)
|
||||||
|
|
||||||
|
url_names = [
|
||||||
|
url.name for url in urls if hasattr(url, "name") and url.name is not None
|
||||||
|
]
|
||||||
|
auditlog_urls = [name for name in url_names if "auditlog" in name]
|
||||||
|
self.assertGreater(len(auditlog_urls), 0)
|
||||||
|
|
||||||
|
@patch("auditlog.mixins.reverse")
|
||||||
|
def test_auditlog_link(self, mock_reverse):
|
||||||
|
"""Test that auditlog_link method returns correct HTML link."""
|
||||||
|
# Mock the reverse function to return a test URL
|
||||||
|
expected_url = f"/admin/test_app/simplemodel/{self.obj.pk}/auditlog/"
|
||||||
|
mock_reverse.return_value = expected_url
|
||||||
|
|
||||||
|
link_html = self.admin.auditlog_link(self.obj)
|
||||||
|
|
||||||
|
self.assertIsInstance(link_html, str)
|
||||||
|
|
||||||
|
self.assertIn("<a href=", link_html)
|
||||||
|
self.assertIn("View</a>", link_html)
|
||||||
|
|
||||||
|
self.assertIn(expected_url, link_html)
|
||||||
|
|
||||||
|
opts = self.obj._meta
|
||||||
|
expected_url_name = f"admin:{opts.app_label}_{opts.model_name}_auditlog"
|
||||||
|
mock_reverse.assert_called_once_with(expected_url_name, args=[self.obj.pk])
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,5 +1,6 @@
|
||||||
# Docs requirements
|
# Docs requirements
|
||||||
django>=3.2,<3.3
|
django>=4.2,<4.3
|
||||||
sphinx
|
sphinx
|
||||||
sphinx_rtd_theme
|
sphinx_rtd_theme
|
||||||
psycopg2-binary
|
psycopg2-binary
|
||||||
|
mysqlclient==2.2.5
|
||||||
|
|
@ -19,10 +19,11 @@ from auditlog import __version__
|
||||||
# documentation root, use os.path.abspath to make it absolute, like shown here.
|
# documentation root, use os.path.abspath to make it absolute, like shown here.
|
||||||
|
|
||||||
# Add sources folder
|
# Add sources folder
|
||||||
sys.path.insert(0, os.path.abspath("../../"))
|
|
||||||
|
sys.path.insert(0, os.path.abspath("../../auditlog_tests"))
|
||||||
|
|
||||||
# Setup Django for autodoc
|
# Setup Django for autodoc
|
||||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "auditlog_tests.test_settings")
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_settings")
|
||||||
import django # noqa: E402
|
import django # noqa: E402
|
||||||
|
|
||||||
django.setup()
|
django.setup()
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,10 @@ The repository can be found at https://github.com/jazzband/django-auditlog/.
|
||||||
|
|
||||||
**Requirements**
|
**Requirements**
|
||||||
|
|
||||||
- Python 3.8 or higher
|
- Python 3.10 or higher
|
||||||
- Django 3.2, 4.2 and 5.0
|
- Django 4.2, 5.0, 5.1, and 5.2
|
||||||
|
|
||||||
Auditlog is currently tested with Python 3.8+ and Django 3.2, 4.2 and 5.0. The latest test report can be found
|
Auditlog is currently tested with Python 3.10+ and Django 4.2, 5.0, 5.1, and 5.2. The latest test report can be found
|
||||||
at https://github.com/jazzband/django-auditlog/actions.
|
at https://github.com/jazzband/django-auditlog/actions.
|
||||||
|
|
||||||
Adding Auditlog to your Django application
|
Adding Auditlog to your Django application
|
||||||
|
|
|
||||||
|
|
@ -55,12 +55,28 @@ A DetailView utilizing the LogAccessMixin could look like the following example:
|
||||||
|
|
||||||
# View code goes here
|
# 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**
|
**Excluding fields**
|
||||||
|
|
||||||
Fields that are excluded will not trigger saving a new log entry and will not show up in the recorded changes.
|
Fields that are excluded will not trigger saving a new log entry and will not show up in the recorded changes.
|
||||||
|
|
||||||
To exclude specific fields from the log you can pass ``include_fields`` resp. ``exclude_fields`` to the ``register``
|
To exclude specific fields from the log you can pass ``include_fields`` or ``exclude_fields`` to the ``register``
|
||||||
method. If ``exclude_fields`` is specified the fields with the given names will not be included in the generated log
|
method. If ``exclude_fields`` is specified the fields with the given names will not be included in the generated log
|
||||||
entries. If ``include_fields`` is specified only the fields with the given names will be included in the generated log
|
entries. If ``include_fields`` is specified only the fields with the given names will be included in the generated log
|
||||||
entries. Explicitly excluding fields through ``exclude_fields`` takes precedence over specifying which fields to
|
entries. Explicitly excluding fields through ``exclude_fields`` takes precedence over specifying which fields to
|
||||||
|
|
@ -116,6 +132,37 @@ For example, to mask the field ``address``, use::
|
||||||
|
|
||||||
auditlog.register(MyModel, mask_fields=['address'])
|
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
|
.. versionadded:: 2.0.0
|
||||||
|
|
||||||
Masking fields
|
Masking fields
|
||||||
|
|
@ -206,6 +253,41 @@ It will be considered when ``AUDITLOG_INCLUDE_ALL_MODELS`` is `True`.
|
||||||
|
|
||||||
.. versionadded:: 3.0.0
|
.. 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**
|
**AUDITLOG_EXCLUDE_TRACKING_MODELS**
|
||||||
|
|
||||||
You can use this setting to exclude models in registration process.
|
You can use this setting to exclude models in registration process.
|
||||||
|
|
@ -233,7 +315,7 @@ It must be a list or tuple. Each item in this setting can be a:
|
||||||
AUDITLOG_INCLUDE_TRACKING_MODELS = (
|
AUDITLOG_INCLUDE_TRACKING_MODELS = (
|
||||||
"<appname>.<model1>",
|
"<appname>.<model1>",
|
||||||
{
|
{
|
||||||
"model": "<appname>.<model1>",
|
"model": "<appname>.<model2>",
|
||||||
"include_fields": ["field1", "field2"],
|
"include_fields": ["field1", "field2"],
|
||||||
"exclude_fields": ["field3", "field4"],
|
"exclude_fields": ["field3", "field4"],
|
||||||
"mapping_fields": {
|
"mapping_fields": {
|
||||||
|
|
@ -276,6 +358,127 @@ If the value is `None`, the default getter will be used.
|
||||||
|
|
||||||
.. versionadded:: 3.0.0
|
.. 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 Django’s standard dotted notation
|
||||||
|
(for example, ``"app_name.ModelName"``).
|
||||||
|
|
||||||
|
.. versionadded:: 3.5.0
|
||||||
|
Custom LogEntry model configuration via ``AUDITLOG_LOGENTRY_MODEL``
|
||||||
|
|
||||||
|
**AUDITLOG_USE_FK_STRING_REPRESENTATION**
|
||||||
|
|
||||||
|
Determines how changes to foreign key fields are recorded in log entries.
|
||||||
|
|
||||||
|
When `True`, changes to foreign key fields are stored using the string representation of related objects.
|
||||||
|
When `False` (default), the primary key of the related objects is stored instead.
|
||||||
|
|
||||||
|
Before version 2.2.0, foreign key changes were stored using the string representation of the related objects.
|
||||||
|
Starting from version 2.2.0, the default behavior was updated to store the primary key of the related objects instead.
|
||||||
|
|
||||||
|
Before:
|
||||||
|
|
||||||
|
.. code-block:: json
|
||||||
|
|
||||||
|
{ "foreign_key_field": ["foo", "bar"] }
|
||||||
|
|
||||||
|
|
||||||
|
After:
|
||||||
|
|
||||||
|
.. code-block:: json
|
||||||
|
|
||||||
|
{ "foreign_key_field": [1, 2] }
|
||||||
|
|
||||||
|
You can use this option to enable the legacy behavior.
|
||||||
|
|
||||||
|
.. warning::
|
||||||
|
|
||||||
|
This reintroduces a known issue https://github.com/jazzband/django-auditlog/issues/421
|
||||||
|
Commission Error: Causes unnecessary LogEntries even though no update occurrs because the string representation in memory changed
|
||||||
|
Omission Error: More common problem, a related object is updated to another object with the same string representation, no update is logged
|
||||||
|
|
||||||
|
Beware of these problem when enabling this setting.
|
||||||
|
|
||||||
|
.. versionadded:: 3.4.0
|
||||||
|
|
||||||
Actors
|
Actors
|
||||||
------
|
------
|
||||||
|
|
||||||
|
|
@ -450,3 +653,26 @@ Django Admin integration
|
||||||
|
|
||||||
When ``auditlog`` is added to your ``INSTALLED_APPS`` setting a customized admin class is active providing an enhanced
|
When ``auditlog`` is added to your ``INSTALLED_APPS`` setting a customized admin class is active providing an enhanced
|
||||||
Django Admin interface for log entries.
|
Django Admin interface for log entries.
|
||||||
|
|
||||||
|
Audit log history view
|
||||||
|
----------------------
|
||||||
|
|
||||||
|
.. versionadded:: 3.2.2
|
||||||
|
|
||||||
|
Use ``AuditlogHistoryAdminMixin`` to add a "View" link in the admin changelist for accessing each object's audit history::
|
||||||
|
|
||||||
|
from auditlog.mixins import AuditlogHistoryAdminMixin
|
||||||
|
|
||||||
|
@admin.register(MyModel)
|
||||||
|
class MyModelAdmin(AuditlogHistoryAdminMixin, admin.ModelAdmin):
|
||||||
|
show_auditlog_history_link = True
|
||||||
|
|
||||||
|
The history page displays paginated log entries with user, timestamp, action, and field changes. Override
|
||||||
|
``auditlog_history_template`` to customize the page layout.
|
||||||
|
|
||||||
|
The mixin provides the following configuration options:
|
||||||
|
|
||||||
|
- ``show_auditlog_history_link``: Set to ``True`` to display the "View" link in the admin changelist
|
||||||
|
- ``auditlog_history_template``: Template to use for rendering the history page (default: ``auditlog/object_history.html``)
|
||||||
|
- ``auditlog_history_per_page``: Number of log entries to display per page (default: 10)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
[tool.black]
|
[tool.black]
|
||||||
target-version = ["py38"]
|
target-version = ["py39"]
|
||||||
|
|
||||||
[tool.isort]
|
[tool.isort]
|
||||||
profile = "black"
|
profile = "black"
|
||||||
|
|
|
||||||
15
runtests.py
15
runtests.py
|
|
@ -1,15 +0,0 @@
|
||||||
#!/usr/bin/env python
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
import django
|
|
||||||
from django.conf import settings
|
|
||||||
from django.test.utils import get_runner
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
os.environ["DJANGO_SETTINGS_MODULE"] = "auditlog_tests.test_settings"
|
|
||||||
django.setup()
|
|
||||||
TestRunner = get_runner(settings)
|
|
||||||
test_runner = TestRunner()
|
|
||||||
failures = test_runner.run_tests(["auditlog_tests"])
|
|
||||||
sys.exit(bool(failures))
|
|
||||||
43
runtests.sh
Executable file
43
runtests.sh
Executable file
|
|
@ -0,0 +1,43 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# Run tests against all supported databases
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Default settings
|
||||||
|
export TEST_DB_USER=${TEST_DB_USER:-testuser}
|
||||||
|
export TEST_DB_PASS=${TEST_DB_PASS:-testpass}
|
||||||
|
export TEST_DB_HOST=${TEST_DB_HOST:-127.0.0.1}
|
||||||
|
export TEST_DB_NAME=${TEST_DB_NAME:-auditlog}
|
||||||
|
|
||||||
|
# Cleanup on exit
|
||||||
|
trap 'docker compose -f auditlog_tests/docker-compose.yml down -v --remove-orphans 2>/dev/null || true' EXIT
|
||||||
|
|
||||||
|
echo "Starting containers..."
|
||||||
|
docker compose -f auditlog_tests/docker-compose.yml up -d
|
||||||
|
|
||||||
|
echo "Waiting for databases..."
|
||||||
|
echo "Waiting for PostgreSQL..."
|
||||||
|
until docker compose -f auditlog_tests/docker-compose.yml exec postgres pg_isready -U ${TEST_DB_USER} -d auditlog >/dev/null 2>&1; do
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Waiting for MySQL..."
|
||||||
|
|
||||||
|
until docker compose -f auditlog_tests/docker-compose.yml exec mysql mysqladmin ping -h 127.0.0.1 -u ${TEST_DB_USER} --password=${TEST_DB_PASS} --silent >/dev/null 2>&1; do
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
echo "Databases ready!"
|
||||||
|
|
||||||
|
# Run tests for each database
|
||||||
|
for backend in sqlite3 postgresql mysql; do
|
||||||
|
echo "Testing $backend..."
|
||||||
|
export TEST_DB_BACKEND=$backend
|
||||||
|
case $backend in
|
||||||
|
postgresql) export TEST_DB_PORT=5432 ;;
|
||||||
|
mysql) export TEST_DB_PORT=3306;;
|
||||||
|
sqlite3) unset TEST_DB_PORT ;;
|
||||||
|
esac
|
||||||
|
tox
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "All tests completed!"
|
||||||
12
setup.py
12
setup.py
|
|
@ -10,11 +10,13 @@ setup(
|
||||||
name="django-auditlog",
|
name="django-auditlog",
|
||||||
use_scm_version={"version_scheme": "post-release"},
|
use_scm_version={"version_scheme": "post-release"},
|
||||||
setup_requires=["setuptools_scm"],
|
setup_requires=["setuptools_scm"],
|
||||||
|
include_package_data=True,
|
||||||
packages=[
|
packages=[
|
||||||
"auditlog",
|
"auditlog",
|
||||||
"auditlog.migrations",
|
"auditlog.migrations",
|
||||||
"auditlog.management",
|
"auditlog.management",
|
||||||
"auditlog.management.commands",
|
"auditlog.management.commands",
|
||||||
|
"auditlog.templatetags",
|
||||||
],
|
],
|
||||||
url="https://github.com/jazzband/django-auditlog",
|
url="https://github.com/jazzband/django-auditlog",
|
||||||
project_urls={
|
project_urls={
|
||||||
|
|
@ -27,20 +29,20 @@ setup(
|
||||||
description="Audit log app for Django",
|
description="Audit log app for Django",
|
||||||
long_description=long_description,
|
long_description=long_description,
|
||||||
long_description_content_type="text/markdown",
|
long_description_content_type="text/markdown",
|
||||||
python_requires=">=3.8",
|
python_requires=">=3.10",
|
||||||
install_requires=["Django>=3.2", "python-dateutil>=2.7.0"],
|
install_requires=["Django>=4.2", "python-dateutil>=2.7.0"],
|
||||||
zip_safe=False,
|
zip_safe=False,
|
||||||
classifiers=[
|
classifiers=[
|
||||||
"Programming Language :: Python :: 3",
|
"Programming Language :: Python :: 3",
|
||||||
"Programming Language :: Python :: 3.8",
|
|
||||||
"Programming Language :: Python :: 3.9",
|
|
||||||
"Programming Language :: Python :: 3.10",
|
"Programming Language :: Python :: 3.10",
|
||||||
"Programming Language :: Python :: 3.11",
|
"Programming Language :: Python :: 3.11",
|
||||||
"Programming Language :: Python :: 3.12",
|
"Programming Language :: Python :: 3.12",
|
||||||
|
"Programming Language :: Python :: 3.13",
|
||||||
"Framework :: Django",
|
"Framework :: Django",
|
||||||
"Framework :: Django :: 3.2",
|
|
||||||
"Framework :: Django :: 4.2",
|
"Framework :: Django :: 4.2",
|
||||||
"Framework :: Django :: 5.0",
|
"Framework :: Django :: 5.0",
|
||||||
|
"Framework :: Django :: 5.1",
|
||||||
|
"Framework :: Django :: 5.2",
|
||||||
"License :: OSI Approved :: MIT License",
|
"License :: OSI Approved :: MIT License",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
|
||||||
54
tox.ini
54
tox.ini
|
|
@ -1,28 +1,39 @@
|
||||||
[tox]
|
[tox]
|
||||||
envlist =
|
envlist =
|
||||||
{py38,py39,py310}-django32
|
{py312}-customlogmodel-django52
|
||||||
{py38,py39,py310,py311}-django42
|
{py310,py311}-django42
|
||||||
{py310,py311,py312}-django{50,main}
|
{py310,py311,py312}-django50
|
||||||
py38-docs
|
{py310,py311,py312,py313}-django51
|
||||||
py38-lint
|
{py310,py311,py312,py313}-django52
|
||||||
|
{py312,py313}-djangomain
|
||||||
|
py310-docs
|
||||||
|
py310-lint
|
||||||
|
py310-checkmigrations
|
||||||
|
|
||||||
|
|
||||||
[testenv]
|
[testenv]
|
||||||
setenv =
|
setenv =
|
||||||
COVERAGE_FILE={toxworkdir}/.coverage.{envname}
|
COVERAGE_FILE={toxworkdir}/.coverage.{envname}.{env:TEST_DB_BACKEND}
|
||||||
|
customlogmodel: AUDITLOG_LOGENTRY_MODEL = custom_logentry_app.CustomLogEntryModel
|
||||||
|
changedir = auditlog_tests
|
||||||
commands =
|
commands =
|
||||||
coverage run --source auditlog runtests.py
|
coverage run --source auditlog ./manage.py test
|
||||||
coverage xml
|
coverage xml
|
||||||
deps =
|
deps =
|
||||||
django32: Django>=3.2,<3.3
|
|
||||||
django42: Django>=4.2,<4.3
|
django42: Django>=4.2,<4.3
|
||||||
django50: Django>=5.0,<5.1
|
django50: Django>=5.0,<5.1
|
||||||
|
django51: Django>=5.1,<5.2
|
||||||
|
django52: Django>=5.2,<5.3
|
||||||
djangomain: https://github.com/django/django/archive/main.tar.gz
|
djangomain: https://github.com/django/django/archive/main.tar.gz
|
||||||
# Test requirements
|
# Test requirements
|
||||||
coverage
|
coverage
|
||||||
codecov
|
codecov
|
||||||
freezegun
|
freezegun
|
||||||
psycopg2-binary
|
psycopg2-binary
|
||||||
|
mysqlclient
|
||||||
|
|
||||||
passenv=
|
passenv=
|
||||||
|
TEST_DB_BACKEND
|
||||||
TEST_DB_HOST
|
TEST_DB_HOST
|
||||||
TEST_DB_USER
|
TEST_DB_USER
|
||||||
TEST_DB_PASS
|
TEST_DB_PASS
|
||||||
|
|
@ -30,26 +41,41 @@ passenv=
|
||||||
TEST_DB_PORT
|
TEST_DB_PORT
|
||||||
|
|
||||||
basepython =
|
basepython =
|
||||||
|
py313: python3.13
|
||||||
py312: python3.12
|
py312: python3.12
|
||||||
py311: python3.11
|
py311: python3.11
|
||||||
py310: python3.10
|
py310: python3.10
|
||||||
py39: python3.9
|
|
||||||
py38: python3.8
|
|
||||||
|
|
||||||
[testenv:py38-docs]
|
[testenv:py310-docs]
|
||||||
changedir = docs/source
|
changedir = docs/source
|
||||||
deps = -rdocs/requirements.txt
|
deps = -rdocs/requirements.txt
|
||||||
commands = sphinx-build -W -b html -d {envtmpdir}/doctrees . {envtmpdir}/html
|
commands = sphinx-build -W -b html -d {envtmpdir}/doctrees . {envtmpdir}/html
|
||||||
|
|
||||||
[testenv:py38-lint]
|
[testenv:py310-lint]
|
||||||
deps = pre-commit
|
deps = pre-commit
|
||||||
commands =
|
commands =
|
||||||
pre-commit run --all-files
|
pre-commit run --all-files
|
||||||
|
|
||||||
|
[testenv:py310-checkmigrations]
|
||||||
|
description = Check for missing migrations
|
||||||
|
changedir = auditlog_tests
|
||||||
|
deps =
|
||||||
|
Django>=4.2
|
||||||
|
psycopg2-binary
|
||||||
|
mysqlclient
|
||||||
|
passenv=
|
||||||
|
TEST_DB_BACKEND
|
||||||
|
TEST_DB_HOST
|
||||||
|
TEST_DB_USER
|
||||||
|
TEST_DB_PASS
|
||||||
|
TEST_DB_NAME
|
||||||
|
TEST_DB_PORT
|
||||||
|
commands =
|
||||||
|
python manage.py makemigrations --check --dry-run
|
||||||
|
|
||||||
[gh-actions]
|
[gh-actions]
|
||||||
python =
|
python =
|
||||||
3.8: py38
|
|
||||||
3.9: py39
|
|
||||||
3.10: py310
|
3.10: py310
|
||||||
3.11: py311
|
3.11: py311
|
||||||
3.12: py312
|
3.12: py312
|
||||||
|
3.13: py313
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue