mirror of
https://github.com/jazzband/django-auditlog.git
synced 2026-03-16 22:20:26 +00:00
Compare commits
324 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 | ||
|
|
1ce39c0149 | ||
|
|
387b0cec22 | ||
|
|
5523b50c2f | ||
|
|
91b6f2e11d | ||
|
|
452f35eef2 | ||
|
|
dbb50e462c | ||
|
|
140719eeb5 | ||
|
|
c581a6e647 | ||
|
|
5072f5d9b1 | ||
|
|
9e987169da | ||
|
|
a034588ec7 | ||
|
|
40ff8ae74d | ||
|
|
2bf675fceb | ||
|
|
6b7f4a7ed4 | ||
|
|
e15a610dc9 | ||
|
|
d4ae5cc2b6 | ||
|
|
2ebaf40476 | ||
|
|
e699e7d118 | ||
|
|
ccc59e85f1 | ||
|
|
459f4b1521 | ||
|
|
720ab2795d | ||
|
|
991d47b1e2 | ||
|
|
ac737fd55c | ||
|
|
af31261946 | ||
|
|
858034b0c1 | ||
|
|
9f1a09bde7 | ||
|
|
c52786855b | ||
|
|
fb1ae7abed | ||
|
|
699d8380b2 | ||
|
|
96a8ded086 | ||
|
|
134ef73723 | ||
|
|
f2591e420b | ||
|
|
3dc4f1a02d | ||
|
|
2c595f174c | ||
|
|
060473919b | ||
|
|
9bcc511e21 | ||
|
|
875c6b789f | ||
|
|
8f7dd324a6 | ||
|
|
034384624d | ||
|
|
7f2a3e395d | ||
|
|
45591463e8 | ||
|
|
0d9fb8d6fc | ||
|
|
bfb3a44296 | ||
|
|
59b50907c0 | ||
|
|
cd0d3ea311 | ||
|
|
fa955cd5c7 | ||
|
|
818c8ca16a | ||
|
|
0cb3eb2b99 | ||
|
|
98fe8d4173 | ||
|
|
fcb6c4ce27 | ||
|
|
7aec22d38c | ||
|
|
ec591800fe | ||
|
|
3a90087d32 | ||
|
|
565a9bbef5 | ||
|
|
06ae048378 | ||
|
|
7a7e2eb2ef | ||
|
|
f6b9e9f931 | ||
|
|
b9a86df456 | ||
|
|
ffa6d34b11 | ||
|
|
7f8edd5456 | ||
|
|
dff0dd0fa5 | ||
|
|
c649629225 | ||
|
|
2a7fc23b29 | ||
|
|
c65c38e539 | ||
|
|
bc6d393390 | ||
|
|
63c88829e0 | ||
|
|
a733cd0852 | ||
|
|
971a4f42f8 | ||
|
|
8e496aadea | ||
|
|
703e3e4ba6 | ||
|
|
27f57a53ff | ||
|
|
cd1ba3d01b | ||
|
|
caf5daa2f8 | ||
|
|
bfaeeab74d | ||
|
|
1674acae19 | ||
|
|
2a93c2086a | ||
|
|
b4dda75fc7 | ||
|
|
4cdc756791 | ||
|
|
e23b091c99 | ||
|
|
1ba3bd9d07 | ||
|
|
96275d5386 | ||
|
|
a6ea91f1bc | ||
|
|
227b0d9fb5 | ||
|
|
2b0bc9efa2 | ||
|
|
36eaaaa2a9 | ||
|
|
f71699a9d0 | ||
|
|
8fe776ae45 | ||
|
|
aa6d977f8b | ||
|
|
90ce363b78 | ||
|
|
c04f5354ef | ||
|
|
487b8ab5f4 | ||
|
|
993cd847fb | ||
|
|
0f57525058 | ||
|
|
a56d0e6f78 | ||
|
|
d74c118834 | ||
|
|
fb90112c50 | ||
|
|
acada9edf9 | ||
|
|
93602bd210 | ||
|
|
527f870034 | ||
|
|
777bd537e7 | ||
|
|
57423fcb3a | ||
|
|
174605d650 | ||
|
|
18868aaaed | ||
|
|
c13d6ec88d | ||
|
|
a00d2c227f | ||
|
|
eb5e873082 | ||
|
|
830152f0f4 | ||
|
|
1cd7d9839d | ||
|
|
4ed056bc2f | ||
|
|
a24b79af0c | ||
|
|
68cde8ffb9 | ||
|
|
f68af3033d | ||
|
|
2f914c17ce | ||
|
|
3e044444c3 | ||
|
|
93907b7a67 | ||
|
|
95929cd5b6 | ||
|
|
7861dae4f9 | ||
|
|
ed3ce76762 | ||
|
|
d7dd32af92 | ||
|
|
2ac014baa5 | ||
|
|
68c7f44f9c | ||
|
|
121fe99cf5 | ||
|
|
6b60d04e44 | ||
|
|
7771075dc0 | ||
|
|
b5edd50f02 | ||
|
|
309eef5d2a | ||
|
|
5df31ea6b5 | ||
|
|
fd35c61fda | ||
|
|
4f4699bf6b | ||
|
|
d928ff7f77 | ||
|
|
11dceb81b8 | ||
|
|
be973ca71f | ||
|
|
10c47181bb | ||
|
|
2e9466d1b4 | ||
|
|
128555fa29 | ||
|
|
dd89c3cefb | ||
|
|
957680e239 | ||
|
|
d9b0d76f3a | ||
|
|
1e7d320a93 | ||
|
|
bcd0d43566 | ||
|
|
ba19a8ca35 | ||
|
|
46abfbdc2d | ||
|
|
340a01d348 | ||
|
|
32694b1324 | ||
|
|
8b47267a43 | ||
|
|
d5d65cae2a | ||
|
|
cdd9f40d84 | ||
|
|
a93f53962a | ||
|
|
6f82d070a9 | ||
|
|
ea180c0065 | ||
|
|
d848c53d08 | ||
|
|
e2d293730c | ||
|
|
6791253ae0 | ||
|
|
e39fab3b83 | ||
|
|
4c0bfabab2 | ||
|
|
de0625b378 | ||
|
|
8a5fa3f236 | ||
|
|
8d8b6bad30 | ||
|
|
bb5f99533e | ||
|
|
dad4fb893b | ||
|
|
0499dfa4c8 | ||
|
|
629d8e62c6 | ||
|
|
77ef852706 | ||
|
|
665217d32f | ||
|
|
7bc39e6d9d | ||
|
|
a30c8bbfc0 | ||
|
|
f516aae4a6 | ||
|
|
d631bcac34 | ||
|
|
7356044693 | ||
|
|
505ee8d054 | ||
|
|
ffd8bb1e85 | ||
|
|
f91e524168 | ||
|
|
edc03e30fa | ||
|
|
f003f3332a | ||
|
|
2c9d7047c9 | ||
|
|
4771fe7830 | ||
|
|
4773a28cf4 | ||
|
|
1d27bc3a04 | ||
|
|
2e9668dc05 | ||
|
|
54dc20e920 | ||
|
|
05c280575f | ||
|
|
33620c30a7 | ||
|
|
c9fac5cfde | ||
|
|
194e1e4c93 | ||
|
|
728fb0439e | ||
|
|
50beb70a19 | ||
|
|
6fe3220a28 | ||
|
|
d5e192c285 | ||
|
|
938431389f | ||
|
|
620d716513 | ||
|
|
3dee9f1555 | ||
|
|
3cd34c4d07 | ||
|
|
1b96d8f828 | ||
|
|
e2913da1bb | ||
|
|
1a437f4e40 | ||
|
|
da7b1441d0 | ||
|
|
ca5aa82714 | ||
|
|
457b04b448 | ||
|
|
55a66fc73a | ||
|
|
2e477ab04a | ||
|
|
2c6bf286b4 | ||
|
|
d77803b3b4 | ||
|
|
3e8d398c8e | ||
|
|
6131430ff7 | ||
|
|
497c83fc83 | ||
|
|
f5bb5cb1a2 | ||
|
|
b700e40f65 | ||
|
|
f08b521b8b | ||
|
|
e9cfdb2e48 | ||
|
|
793cb45960 | ||
|
|
910089597e | ||
|
|
50da34125c | ||
|
|
31418d54f2 | ||
|
|
565239180e | ||
|
|
7bb17fd5d2 | ||
|
|
33fa249071 | ||
|
|
346105dcf9 | ||
|
|
f4edfc0592 | ||
|
|
f14f6b34ee | ||
|
|
469fe362de | ||
|
|
228c5949fb | ||
|
|
c619b8c606 | ||
|
|
2010b49d06 | ||
|
|
3acab4322b | ||
|
|
4e7c640ba0 | ||
|
|
ee8a700b1b |
133 changed files with 10482 additions and 2957 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
|
||||
53
.github/workflows/release.yml
vendored
Normal file
53
.github/workflows/release.yml
vendored
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: github.repository == 'jazzband/django-auditlog'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.10'
|
||||
|
||||
- name: Get pip cache dir
|
||||
id: pip-cache
|
||||
run: |
|
||||
echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Cache
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ${{ steps.pip-cache.outputs.dir }}
|
||||
key: release-${{ hashFiles('**/setup.py') }}
|
||||
restore-keys: |
|
||||
release-
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install -U pip
|
||||
python -m pip install -U setuptools==75.6.0 twine==6.0.1 wheel pkginfo
|
||||
|
||||
- name: Build package
|
||||
run: |
|
||||
python setup.py --version
|
||||
python setup.py sdist --format=gztar bdist_wheel
|
||||
twine check dist/*
|
||||
|
||||
- name: Upload packages to Jazzband
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags')
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
user: jazzband
|
||||
password: ${{ secrets.JAZZBAND_RELEASE_KEY }}
|
||||
repository-url: https://jazzband.co/projects/django-auditlog/upload
|
||||
127
.github/workflows/test.yml
vendored
Normal file
127
.github/workflows/test.yml
vendored
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
name: Test
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
test-sqlite:
|
||||
name: SQLite • Python ${{ matrix.python-version }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
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:
|
||||
postgres:
|
||||
image: postgres:15
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: auditlog
|
||||
ports:
|
||||
- 5432/tcp
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 10
|
||||
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: postgresql
|
||||
|
||||
- name: Run tests
|
||||
env:
|
||||
TEST_DB_BACKEND: postgresql
|
||||
TEST_DB_HOST: localhost
|
||||
TEST_DB_USER: postgres
|
||||
TEST_DB_PASS: postgres
|
||||
TEST_DB_NAME: auditlog
|
||||
TEST_DB_PORT: ${{ job.services.postgres.ports[5432] }}
|
||||
|
||||
run: tox -v
|
||||
|
||||
- name: Upload coverage
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
name: PostgreSQL • Python ${{ matrix.python-version }}
|
||||
|
||||
test-mysql:
|
||||
name: MySQL • Python ${{ matrix.python-version }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
||||
services:
|
||||
mysql:
|
||||
image: mysql:8.4
|
||||
env:
|
||||
MYSQL_DATABASE: auditlog
|
||||
MYSQL_USER: mysql
|
||||
MYSQL_PASSWORD: mysql
|
||||
MYSQL_ROOT_PASSWORD: mysql
|
||||
ports:
|
||||
- 3306/tcp
|
||||
options: >-
|
||||
--health-cmd="sh -c 'export MYSQL_PWD=\"$MYSQL_ROOT_PASSWORD\"; mysqladmin ping -h 127.0.0.1 --protocol=TCP -uroot --silent || exit 1'"
|
||||
--health-interval=10s
|
||||
--health-timeout=5s
|
||||
--health-retries=20
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Install MySQL client libraries
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libmysqlclient-dev pkg-config mysql-client
|
||||
|
||||
- name: Setup Python and dependencies
|
||||
uses: ./.github/actions/setup-python-deps
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache-key-prefix: mysql
|
||||
|
||||
- name: Run tests
|
||||
env:
|
||||
TEST_DB_BACKEND: mysql
|
||||
TEST_DB_HOST: 127.0.0.1
|
||||
TEST_DB_USER: root
|
||||
TEST_DB_PASS: mysql
|
||||
TEST_DB_NAME: auditlog
|
||||
TEST_DB_PORT: ${{ job.services.mysql.ports[3306] }}
|
||||
run: tox -v
|
||||
|
||||
- name: Upload coverage
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
name: MySQL • Python ${{ matrix.python-version }}
|
||||
90
.gitignore
vendored
90
.gitignore
vendored
|
|
@ -1,12 +1,82 @@
|
|||
*.db
|
||||
*.egg-info
|
||||
*.log
|
||||
*.pot
|
||||
*.pyc
|
||||
.idea
|
||||
.project
|
||||
.pydevproject
|
||||
.coverage
|
||||
venv/
|
||||
### Python template
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
pip-wheel-metadata/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# pyenv
|
||||
.python-version
|
||||
|
||||
# pipenv
|
||||
Pipfile.lock
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
|
||||
__pypackages__/
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
### JetBrains
|
||||
.idea/
|
||||
.vscode/
|
||||
|
|
|
|||
29
.pre-commit-config.yaml
Normal file
29
.pre-commit-config.yaml
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
---
|
||||
repos:
|
||||
- repo: https://github.com/psf/black-pre-commit-mirror
|
||||
rev: 26.3.0
|
||||
hooks:
|
||||
- id: black
|
||||
language_version: python3.10
|
||||
args:
|
||||
- "--target-version"
|
||||
- "py310"
|
||||
- repo: https://github.com/PyCQA/flake8
|
||||
rev: "7.3.0"
|
||||
hooks:
|
||||
- id: flake8
|
||||
args: ["--max-line-length", "110"]
|
||||
- repo: https://github.com/PyCQA/isort
|
||||
rev: 8.0.1
|
||||
hooks:
|
||||
- id: isort
|
||||
- repo: https://github.com/asottile/pyupgrade
|
||||
rev: v3.21.2
|
||||
hooks:
|
||||
- id: pyupgrade
|
||||
args: [--py310-plus]
|
||||
- repo: https://github.com/adamchainz/django-upgrade
|
||||
rev: 1.30.0
|
||||
hooks:
|
||||
- id: django-upgrade
|
||||
args: [--target-version, "4.2"]
|
||||
27
.readthedocs.yaml
Normal file
27
.readthedocs.yaml
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
# Read the Docs configuration file for Sphinx projects
|
||||
# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
|
||||
|
||||
# Required
|
||||
version: 2
|
||||
|
||||
# Set the OS, Python version and other tools you might need
|
||||
build:
|
||||
os: ubuntu-22.04
|
||||
tools:
|
||||
python: "3"
|
||||
|
||||
# Build documentation in the "docs/" directory with Sphinx
|
||||
sphinx:
|
||||
configuration: docs/source/conf.py
|
||||
|
||||
# Optionally build your docs in additional formats such as PDF and ePub
|
||||
formats: all
|
||||
|
||||
# Optional but recommended, declare the Python requirements required
|
||||
# to build your documentation
|
||||
# See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html
|
||||
python:
|
||||
install:
|
||||
- requirements: docs/requirements.txt
|
||||
- method: pip
|
||||
path: .
|
||||
65
.travis.yml
65
.travis.yml
|
|
@ -1,65 +0,0 @@
|
|||
# Config file for automatic testing at travis-ci.org
|
||||
|
||||
dist: xenial
|
||||
sudo: required
|
||||
language: python
|
||||
services:
|
||||
- postgresql
|
||||
|
||||
addons:
|
||||
postgresql: "10"
|
||||
|
||||
matrix:
|
||||
include:
|
||||
- python: 2.7
|
||||
env: TOXENV=py27-django-111
|
||||
|
||||
- python: 3.4
|
||||
env: TOXENV=py34-django-111
|
||||
- python: 3.4
|
||||
env: TOXENV=py34-django-20
|
||||
|
||||
- python: 3.5
|
||||
env: TOXENV=py35-django-111
|
||||
- python: 3.5
|
||||
env: TOXENV=py35-django-20
|
||||
- python: 3.5
|
||||
env: TOXENV=py35-django-21
|
||||
- python: 3.5
|
||||
env: TOXENV=py35-django-22
|
||||
|
||||
- python: 3.6
|
||||
env: TOXENV=py36-django-111
|
||||
- python: 3.6
|
||||
env: TOXENV=py36-django-20
|
||||
- python: 3.6
|
||||
env: TOXENV=py36-django-21
|
||||
- python: 3.6
|
||||
env: TOXENV=py36-django-22
|
||||
|
||||
- python: 3.7
|
||||
env: TOXENV=py37-django-111
|
||||
- python: 3.7
|
||||
env: TOXENV=py37-django-20
|
||||
- python: 3.7
|
||||
env: TOXENV=py37-django-21
|
||||
- python: 3.7
|
||||
env: TOXENV=py37-django-22
|
||||
|
||||
fast_finish: true
|
||||
|
||||
install: pip install -r requirements-test.txt
|
||||
|
||||
script: tox
|
||||
|
||||
after_success:
|
||||
- codecov -e TOX_ENV
|
||||
|
||||
deploy:
|
||||
provider: pypi
|
||||
# PyPI credentials supplied with environment variables from repository settings
|
||||
on:
|
||||
repo: jjkester/django-auditlog
|
||||
branch: stable
|
||||
condition: $TOXENV = py36-django-20
|
||||
edge: true
|
||||
448
CHANGELOG.md
Normal file
448
CHANGELOG.md
Normal file
|
|
@ -0,0 +1,448 @@
|
|||
# Changes
|
||||
|
||||
## Next Release
|
||||
|
||||
#### Fixes
|
||||
|
||||
- `KeyError` when calling `changes_str` on a log entry that tracks many-to-many field changes ([#798](https://github.com/jazzband/django-auditlog/pull/798))
|
||||
|
||||
## 3.4.1 (2025-12-13)
|
||||
|
||||
#### Fixes
|
||||
|
||||
- Fix AttributeError when AUDITLOG_LOGENTRY_MODEL is not set ([#789](https://github.com/jazzband/django-auditlog/pull/789))
|
||||
|
||||
## 3.4.0 (2025-12-04)
|
||||
|
||||
#### Improvements
|
||||
|
||||
- feat: Add CustomLogEntry model support and update tests ([#764)](https://github.com/jazzband/django-auditlog/pull/764))
|
||||
- feat: Add `AUDITLOG_USE_FK_STRING_REPRESENTATION` setting that controls how foreign key changes are represented ([#779)](https://github.com/jazzband/django-auditlog/pull/779))
|
||||
- Add `AUDITLOG_USE_BASE_MANAGER` setting to override default manager use ([#766](https://github.com/jazzband/django-auditlog/pull/766))
|
||||
- Drop 'Python 3.9' support ([#773](https://github.com/jazzband/django-auditlog/pull/773))
|
||||
|
||||
#### Fixes
|
||||
|
||||
- Make diffing more robust for polymorphic models ([#784](https://github.com/jazzband/django-auditlog/pull/784))
|
||||
- Amend setup configuration to include non-python package files ([#769](https://github.com/jazzband/django-auditlog/pull/769))
|
||||
|
||||
## 3.3.0 (2025-09-18)
|
||||
|
||||
#### Improvements
|
||||
|
||||
- CI: Extend CI and local test coverage to MySQL and SQLite ([#744](https://github.com/jazzband/django-auditlog/pull/744))
|
||||
- feat: Add audit log history view to Django Admin. ([#743](https://github.com/jazzband/django-auditlog/pull/743))
|
||||
- Fix Expression test compatibility for Django 6.0+ ([#759](https://github.com/jazzband/django-auditlog/pull/759))
|
||||
- Add I18N Support ([#762](https://github.com/jazzband/django-auditlog/pull/762))
|
||||
|
||||
## 3.2.1 (2025-07-03)
|
||||
|
||||
#### Improvements
|
||||
|
||||
- Confirm Django 5.2 support. ([#730](https://github.com/jazzband/django-auditlog/pull/730))
|
||||
|
||||
#### Fixes
|
||||
|
||||
- fix: ```AUDITLOG_STORE_JSON_CHANGES=True``` was not respected during updates and deletions. ([#732](https://github.com/jazzband/django-auditlog/pull/732))
|
||||
|
||||
## 3.2.0 (2025-06-26)
|
||||
|
||||
#### Improvements
|
||||
|
||||
- feat: Support storing JSON in the changes field when ```AUDITLOG_STORE_JSON_CHANGES``` is enabled. ([#719](https://github.com/jazzband/django-auditlog/pull/719))
|
||||
- feat: Added `AUDITLOG_MASK_CALLABLE` setting to allow custom masking functions ([#725](https://github.com/jazzband/django-auditlog/pull/725))
|
||||
|
||||
## 3.1.2 (2025-04-26)
|
||||
|
||||
#### Fixes
|
||||
|
||||
- CI: Pine twine and setuptools to fix release
|
||||
|
||||
## 3.1.1 (2025-04-16)
|
||||
|
||||
#### Fixes
|
||||
|
||||
- CI: Add required pkginfo to release workflow
|
||||
|
||||
## 3.1.0 (2025-04-15)
|
||||
|
||||
#### Improvements
|
||||
|
||||
- feat: Support masking field names globally when ```AUDITLOG_INCLUDE_ALL_MODELS``` is enabled
|
||||
via `AUDITLOG_MASK_TRACKING_FIELDS` setting. ([#702](https://github.com/jazzband/django-auditlog/pull/702))
|
||||
- feat: Added `LogEntry.actor_email` field. ([#641](https://github.com/jazzband/django-auditlog/pull/641))
|
||||
- Add Python 3.13 support. ([#697](https://github.com/jazzband/django-auditlog/pull/671))
|
||||
- feat: Added `LogEntry.remote_port` field. ([#671](https://github.com/jazzband/django-auditlog/pull/671))
|
||||
- feat: Added `truncate` option to `auditlogflush` management command. ([#681](https://github.com/jazzband/django-auditlog/pull/681))
|
||||
- feat: Added `AUDITLOG_CHANGE_DISPLAY_TRUNCATE_LENGTH` settings to keep or truncate strings of `changes_display_dict` property at variable length. ([#684](https://github.com/jazzband/django-auditlog/pull/684))
|
||||
- Drop Python 3.8 support. ([#678](https://github.com/jazzband/django-auditlog/pull/678))
|
||||
- Confirm Django 5.1 support and drop Django 3.2 support. ([#677](https://github.com/jazzband/django-auditlog/pull/677))
|
||||
|
||||
#### Fixes
|
||||
|
||||
- fix: Use sender instead of receiver for `m2m_changed` signal ID to prevent duplicate entries for models that share a related model. ([#686](https://github.com/jazzband/django-auditlog/pull/686))
|
||||
- Fixed a problem when setting `Value(None)` in `JSONField` ([#646](https://github.com/jazzband/django-auditlog/pull/646))
|
||||
- Fixed a problem when setting `django.db.models.functions.Now()` in `DateTimeField` ([#635](https://github.com/jazzband/django-auditlog/pull/635))
|
||||
- Use the [default manager](https://docs.djangoproject.com/en/5.1/topics/db/managers/#default-managers) instead of `objects` to support custom model managers. ([#705](https://github.com/jazzband/django-auditlog/pull/705))
|
||||
- Fixed crashes when cloning objects with `pk=None` ([#707](https://github.com/jazzband/django-auditlog/pull/707))
|
||||
|
||||
## 3.0.0 (2024-04-12)
|
||||
|
||||
#### Fixes
|
||||
|
||||
- Fixed logging problem related to django translation before logging ([#624](https://github.com/jazzband/django-auditlog/pull/624))
|
||||
- Fixed manuall logging when model is not registered ([#627](https://github.com/jazzband/django-auditlog/pull/627))
|
||||
|
||||
#### Improvements
|
||||
- feat: Excluding ip address when `AUDITLOG_DISABLE_REMOTE_ADDR` is set to True ([#620](https://github.com/jazzband/django-auditlog/pull/620))
|
||||
|
||||
## 3.0.0-beta.4 (2024-01-02)
|
||||
|
||||
#### Improvements
|
||||
|
||||
- feat: If any receiver returns False, no logging will be made. This can be useful if logging should be conditionally enabled / disabled ([#590](https://github.com/jazzband/django-auditlog/pull/590))
|
||||
- Django: Confirm Django 5.0 support ([#598](https://github.com/jazzband/django-auditlog/pull/598))
|
||||
- Django: Drop Django 4.1 support ([#598](https://github.com/jazzband/django-auditlog/pull/598))
|
||||
|
||||
## 3.0.0-beta.3 (2023-11-13)
|
||||
|
||||
#### Improvements
|
||||
|
||||
- Python: Confirm Python 3.12 support ([#572](https://github.com/jazzband/django-auditlog/pull/572))
|
||||
- feat: `thread.local` replaced with `ContextVar` to improve context managers in Django 4.2+
|
||||
|
||||
#### Fixes
|
||||
|
||||
- fix: Handle `ObjectDoesNotExist` in evaluation of `object_repr` ([#592](https://github.com/jazzband/django-auditlog/pull/592))
|
||||
|
||||
## 3.0.0-beta.2 (2023-10-05)
|
||||
|
||||
#### Breaking Changes
|
||||
- feat: stop deleting old log entries when a model with the same pk is created (i.e. the pk value is reused) ([#559](https://github.com/jazzband/django-auditlog/pull/559))
|
||||
|
||||
#### Fixes
|
||||
* fix: only fire the `post_log` signal when the log is created or when there is an error in the process ([#561](https://github.com/jazzband/django-auditlog/pull/561))
|
||||
* fix: don't set the correlation_id if the `AUDITLOG_CID_GETTER` is `None` ([#565](https://github.com/jazzband/django-auditlog/pull/565))
|
||||
|
||||
## 3.0.0-beta.1 (2023-08-29)
|
||||
|
||||
#### Breaking Changes
|
||||
|
||||
- feat: Change `LogEntry.change` field type to `JSONField` rather than `TextField`. This change include a migration that may take time to run depending on the number of records on your `LogEntry` table ([#407](https://github.com/jazzband/django-auditlog/pull/407))([#495](https://github.com/jazzband/django-auditlog/pull/495))
|
||||
- Python: Drop support for Python 3.7 ([#546](https://github.com/jazzband/django-auditlog/pull/546))
|
||||
- feat: Set `AuditlogHistoryField.delete_related` to `False` by default. This is different from the default configuration of Django's `GenericRelation`, but we should not erase the audit log of objects on deletion by default. ([#557](https://github.com/jazzband/django-auditlog/pull/557))
|
||||
|
||||
#### Improvements
|
||||
|
||||
- Changes the view when it has changes in fields `JSONField`. The `JSONField.encoder` is assigned to `json.dumps`. ([#489](https://github.com/jazzband/django-auditlog/pull/489))
|
||||
- feat: Added support for Correlation ID. ([#481](https://github.com/jazzband/django-auditlog/pull/481))
|
||||
- feat: Added pre-log and post-log signals. ([#483](https://github.com/jazzband/django-auditlog/pull/483))
|
||||
- feat: Make timestamp in LogEntry overwritable. ([#476](https://github.com/jazzband/django-auditlog/pull/476))
|
||||
- feat: Support excluding field names globally when ```AUDITLOG_INCLUDE_ALL_MODELS``` is enabled. ([#498](https://github.com/jazzband/django-auditlog/pull/498))
|
||||
- feat: Improved auto model registration to include auto-created models and exclude non-managed models, and automatically register m2m fields for models. ([#550](https://github.com/jazzband/django-auditlog/pull/550))
|
||||
|
||||
#### Fixes
|
||||
|
||||
- fix: Audit changes to FK fields when saved using `*_id` naming. ([#525](https://github.com/jazzband/django-auditlog/pull/525))
|
||||
- fix: Fix a bug in audit log admin page when `USE_TZ=False`. ([#511](https://github.com/jazzband/django-auditlog/pull/511))
|
||||
- fix: Make sure `LogEntry.changes_dict()` returns an empty dict instead of `None` when `json.loads()` returns `None`. ([#472](https://github.com/jazzband/django-auditlog/pull/472))
|
||||
- fix: Always set remote_addr even if the request has no authenticated user. ([#484](https://github.com/jazzband/django-auditlog/pull/484))
|
||||
- fix: Fix a bug in getting field's `verbose_name` when model is not accessible. ([508](https://github.com/jazzband/django-auditlog/pull/508))
|
||||
- fix: Fix a bug in `serialized_data` with F expressions. ([508](https://github.com/jazzband/django-auditlog/pull/508))
|
||||
- fix: Make log entries read-only in the admin. ([#449](https://github.com/jazzband/django-auditlog/pull/449), [#556](https://github.com/jazzband/django-auditlog/pull/556)) (applied again after being reverted in 2.2.2)
|
||||
|
||||
## 2.2.2 (2023-01-16)
|
||||
|
||||
#### Fixes
|
||||
- fix: revert [#449](https://github.com/jazzband/django-auditlog/pull/449) "Make log entries read-only in the admin" as it breaks deletion of any auditlogged model through the admin when `AuditlogHistoryField` is used. ([#496](https://github.com/jazzband/django-auditlog/pull/496))
|
||||
|
||||
## 2.2.1 (2022-11-28)
|
||||
|
||||
#### Fixes
|
||||
|
||||
- fix: Make log entries read-only in the admin. ([#449](https://github.com/jazzband/django-auditlog/pull/449))
|
||||
- fix: Handle IPv6 addresses in `X-Forwarded-For`. ([#457](https://github.com/jazzband/django-auditlog/pull/457))
|
||||
|
||||
## 2.2.0 (2022-10-07)
|
||||
|
||||
#### Improvements
|
||||
- feat: Add `ACCESS` action to `LogEntry` model and allow object access to be logged. ([#436](https://github.com/jazzband/django-auditlog/pull/436))
|
||||
- feat: Add `serialized_data` field on `LogEntry` model. ([#412](https://github.com/jazzband/django-auditlog/pull/412))
|
||||
- feat: Display the field name as it would be displayed in Django Admin or use `mapping_field` if available [#428](https://github.com/jazzband/django-auditlog/pull/428)
|
||||
- feat: New context manager `disable_auditlog` to turn off logging and a new setting `AUDITLOG_DISABLE_ON_RAW_SAVE`
|
||||
to disable it during raw-save operations like loaddata. [#446](https://github.com/jazzband/django-auditlog/pull/446)
|
||||
- Python: Confirm Python 3.11 support ([#447](https://github.com/jazzband/django-auditlog/pull/447))
|
||||
- feat: Replace the `django.utils.timezone.utc` by `datetime.timezone.utc`. [#448](https://github.com/jazzband/django-auditlog/pull/448)
|
||||
|
||||
#### Fixes
|
||||
|
||||
- fix: Foreign key values are used to check for changes in related fields instead of object representations. When changes are detected, the foreign key value is persisted in `LogEntry.changes` field instead of object representations. ([#420](https://github.com/jazzband/django-auditlog/pull/420))
|
||||
- fix: Display `created` timestamp in server timezone ([#404](https://github.com/jazzband/django-auditlog/pull/404))
|
||||
- fix: Handle port in `remote_addr` ([#417](https://github.com/jazzband/django-auditlog/pull/417))
|
||||
- fix: Handle the error with AttributeError: 'OneToOneRel' error occur during a `PolymorphicModel` has relation with other models ([#429](https://github.com/jazzband/django-auditlog/pull/429))
|
||||
- fix: Support search by custom USERNAME_FIELD ([#432](https://github.com/jazzband/django-auditlog/pull/432))
|
||||
|
||||
## 2.1.1 (2022-07-27)
|
||||
|
||||
#### Improvements
|
||||
|
||||
- feat: Display the diff for deleted objects in the admin ([#396](https://github.com/jazzband/django-auditlog/pull/396))
|
||||
- Django: Confirm Django 4.1 support ([#406](https://github.com/jazzband/django-auditlog/pull/406))
|
||||
|
||||
#### Fixes
|
||||
|
||||
- fix: Pin `python-dateutil` to 2.7.0 or higher for compatibility with Python 3.10 ([#401](https://github.com/jazzband/django-auditlog/pull/401))
|
||||
|
||||
## 2.1.0 (2022-06-27)
|
||||
|
||||
#### Improvements
|
||||
|
||||
- feat: Add `--before-date` option to `auditlogflush` to support retention windows ([#365](https://github.com/jazzband/django-auditlog/pull/365))
|
||||
- feat: Add db_index to the `LogEntry.timestamp` column ([#364](https://github.com/jazzband/django-auditlog/pull/364))
|
||||
- feat: Add register model from settings ([#368](https://github.com/jazzband/django-auditlog/pull/368))
|
||||
- Context manager set_actor() for use in Celery tasks ([#262](https://github.com/jazzband/django-auditlog/pull/262))
|
||||
- Tracking of changes in many-to-many fields ([#309](https://github.com/jazzband/django-auditlog/pull/309))
|
||||
|
||||
#### Fixes
|
||||
|
||||
- Fix inconsistent changes with JSONField ([#355](https://github.com/jazzband/django-auditlog/pull/355))
|
||||
- Disable `add` button in admin ui ([#378](https://github.com/jazzband/django-auditlog/pull/378))
|
||||
- Fix n+1 query problem([#381](https://github.com/jazzband/django-auditlog/pull/381))
|
||||
|
||||
## 2.0.0 (2022-05-09)
|
||||
|
||||
#### Improvements
|
||||
|
||||
- feat: enable use of replica database (delegating the choice to `DATABASES_ROUTER`) ([#359](https://github.com/jazzband/django-auditlog/pull/359))
|
||||
- Add `mask_fields` argument in `register` to mask sensitive information when logging ([#310](https://github.com/jazzband/django-auditlog/pull/310))
|
||||
- Django: Drop 2.2 support. `django_jsonfield_backport` is not required anymore ([#370](https://github.com/jazzband/django-auditlog/pull/370))
|
||||
- Remove `default_app_config` configuration ([#372](https://github.com/jazzband/django-auditlog/pull/372))
|
||||
|
||||
#### Important notes
|
||||
|
||||
- LogEntry no longer save to same database instance is using
|
||||
|
||||
## 1.0.0 (2022-01-24)
|
||||
|
||||
### Final
|
||||
|
||||
#### Improvements
|
||||
|
||||
- build: add classifiers for Python and Django
|
||||
- build: replace django-jsonfield with django-jsonfield-backport ([#339](https://github.com/jazzband/django-auditlog/pull/339))
|
||||
- ci: replace Travis with Github Actions
|
||||
- docs: follow Jazzband guidelines (badge, how to contribute, code of conduct) ([#269](https://github.com/jazzband/django-auditlog/pull/269))
|
||||
- docs: add a changelog
|
||||
- docs: remove note about maintenance
|
||||
- docs: update the release strategy
|
||||
- docs: use the latest django LTS (3.2) to build docs
|
||||
- feat: add a db index to `LogEntry`'s `action` field ([#236](https://github.com/jazzband/django-auditlog/pull/236))
|
||||
- feat: add the content type to `resource` field
|
||||
- feat: add the `actor` username to search fields in admin
|
||||
- refactor: lint the code with Black and isort
|
||||
- tests: init pre-commit config
|
||||
- Python: add 3.9 and 3.10 support, drop 3.5 and 3.6 support
|
||||
- Django: add 3.2 (LTS) and 4.0 support, drop 3.0 and 3.1 support
|
||||
|
||||
#### Fixes
|
||||
|
||||
- docs: replace `MIDDLEWARE_CLASSES` with `MIDDLEWARE`
|
||||
- Remove old django (< 1.9) related codes
|
||||
- Replace deprecated `smart_text()` with `smart_str()`
|
||||
- Replace `ugettext` with `gettext` for Django 4
|
||||
- Support Django's save method `update_fields` kwarg ([#336](https://github.com/jazzband/django-auditlog/pull/336))
|
||||
- Fix invalid escape sequence on Python 3.7
|
||||
|
||||
### Alpha 1 (1.0a1, 2020-09-07)
|
||||
|
||||
#### Improvements
|
||||
|
||||
- Refactor the `auditlogflush` management command
|
||||
- Clean up project structure
|
||||
- Python: add 3.8 support, drop 2.7 and 3.4 support
|
||||
- Django: add 3.0 and 3.1 support, drop 1.11, 2.0 and 2.1 support
|
||||
|
||||
#### Fixes
|
||||
|
||||
- Fix field choices diff
|
||||
- Allow higher versions of python-dateutil than 2.6.0
|
||||
|
||||
## 0.4.8 (2019-11-12)
|
||||
|
||||
### Improvements
|
||||
|
||||
- Add support for PostgreSQL 10
|
||||
|
||||
## 0.4.7 (2019-12-19)
|
||||
|
||||
### Improvements
|
||||
|
||||
- Improve support multiple database (PostgreSQL, MySQL)
|
||||
- Django: add 2.1 and 2.2 support, drop < 1.11 versions
|
||||
- Python: add 3.7 support
|
||||
|
||||
## 0.4.6 (2018-09-18)
|
||||
|
||||
### Features
|
||||
|
||||
- Allow `AuditlogHistoryField` to block cascading deletes ([#172](https://github.com/jazzband/django-auditlog/pull/172))
|
||||
|
||||
### Improvements
|
||||
|
||||
- Add Python classifiers for supported Python versions ([#176](https://github.com/jazzband/django-auditlog/pull/176))
|
||||
- Update README to include steps to release ([#185](https://github.com/jazzband/django-auditlog/pull/185))
|
||||
|
||||
### Fixes
|
||||
|
||||
- Fix the rendering of the `msg` field with Django 2.0 ([#166](https://github.com/jazzband/django-auditlog/pull/166))
|
||||
- Mark `LogEntryAdminMixin` methods output as safe where required ([#167](https://github.com/jazzband/django-auditlog/pull/167))
|
||||
|
||||
## 0.4.5 (2018-01-12)
|
||||
|
||||
### Improvements
|
||||
|
||||
Added support for Django 2.0, along with a number of bug fixes.
|
||||
|
||||
## 0.4.4 (2017-11-17)
|
||||
|
||||
### Improvements
|
||||
|
||||
- Use [Tox](https://tox.wiki) to run tests
|
||||
- Use Codecov to check to coverage before merging
|
||||
- Django: drop 1.9 support, add 1.11 (LTS) support
|
||||
- Python: tests against 2.7, 3.4, 3.5 and 3.6 versions
|
||||
- Add `python-dateutil` to requirements
|
||||
|
||||
### Fixes
|
||||
|
||||
- Support models with UUID primary keys ([#111](https://github.com/jazzband/django-auditlog/pull/111))
|
||||
- Add management commands package to setup.py ([#130](https://github.com/jazzband/django-auditlog/pull/130))
|
||||
- Add `changes_display_dict` property to `LogEntry` model to display diff in a more human readable format ([#94](https://github.com/jazzband/django-auditlog/pull/94))
|
||||
|
||||
## 0.4.3 (2017-02-16)
|
||||
|
||||
### Fixes
|
||||
|
||||
- Fixes cricital bug in admin mixin making the library only usable on Django 1.11
|
||||
|
||||
## 0.4.2 (2017-02-16)
|
||||
|
||||
_As it turns out, haste is never good. Due to the focus on quickly releasing this version a nasty bug was not spotted, which makes this version only usable with Django 1.11 and above. Upgrading to 0.4.3 is not only encouraged but most likely necessary. Apologies for the inconvenience and lacking quality control._
|
||||
|
||||
### Improvements
|
||||
|
||||
- Models can be registered with decorators now
|
||||
|
||||
### Fixes
|
||||
|
||||
- A lot, yes, [_really_ a lot](https://github.com/jjkester/django-auditlog/milestone/8?closed=1), of fixes for the admin integration
|
||||
- Flush command fixed for Django 1.10
|
||||
|
||||
## 0.4.1 (2016-12-27)
|
||||
|
||||
### Improvements
|
||||
|
||||
- Improved Django Admin pages
|
||||
|
||||
### Fixes
|
||||
|
||||
- Fixed multithreading issue where the wrong user was written to the log
|
||||
|
||||
## 0.4.0 (2016-08-17)
|
||||
|
||||
### Breaking changes
|
||||
|
||||
- Dropped support for Django 1.7
|
||||
- Updated dependencies - _please check whether your project works with these higher versions_
|
||||
|
||||
### New features
|
||||
|
||||
- Management command for deleting all log entries
|
||||
- Added admin interface (thanks, @crackjack)
|
||||
|
||||
### Improvements
|
||||
|
||||
- Django: add 1.10 support
|
||||
|
||||
### Fixes
|
||||
|
||||
- Solved migration error for MySQL users
|
||||
|
||||
## 0.3.3 (2016-01-23)
|
||||
|
||||
### Fixes
|
||||
|
||||
- fix `unregister` method
|
||||
- `LogEntry.objects.get_for_objects` works properly on PostgreSQL
|
||||
- Added index in 0.3.2 no longer breaks for users with MySQL databases
|
||||
|
||||
### Important notes
|
||||
|
||||
- The `object_pk` field is now limited to 255 chars
|
||||
|
||||
## 0.3.2 (2015-10-19)
|
||||
|
||||
### New functionality
|
||||
|
||||
- Django: support 1.9
|
||||
|
||||
### Improvements
|
||||
|
||||
- Enhanced performance for non-integer primary key lookups
|
||||
|
||||
## 0.3.1 (2015-07-29)
|
||||
|
||||
### Fixes
|
||||
|
||||
- Auditlog data is now correctly stored in the thread.
|
||||
|
||||
## 0.3.0 (2015-07-22)
|
||||
|
||||
### Breaking changes
|
||||
|
||||
- Django: drop out-of-date versions support, support 1.7+
|
||||
- South is no longer supported
|
||||
|
||||
### New functionality
|
||||
|
||||
- Workaround for many-to-many support
|
||||
- Additional data
|
||||
- Python: support 2.7 and 3.4
|
||||
|
||||
### Improvements
|
||||
|
||||
- Better diffs
|
||||
- Remote address is logged through middleware
|
||||
- Better documentation
|
||||
- Compatibility with [django-polymorphic](https://pypi.org/project/django-polymorphic/)
|
||||
|
||||
## 0.2.1 (2014-07-08)
|
||||
|
||||
### New functionality
|
||||
|
||||
- South compatibility for `AuditlogHistoryField`
|
||||
|
||||
## 0.2.0 (2014-03-08)
|
||||
|
||||
Although this release contains mostly bugfixes, the improvements were significant enough to justify a higher version number.
|
||||
|
||||
### Improvements
|
||||
|
||||
- Signal disconnection fixed
|
||||
- Model diffs use unicode strings instead of regular strings
|
||||
- Tests on middleware
|
||||
|
||||
## 0.1.1 (2013-12-12)
|
||||
|
||||
### New functionality
|
||||
|
||||
- Utility methods for using log entry data
|
||||
|
||||
### Improvements
|
||||
|
||||
- Only save a new log entry if there are actual changes
|
||||
- Better way of loading the user model in the middleware
|
||||
|
||||
## 0.1.0 (2013-10-21)
|
||||
|
||||
First beta release of Auditlog.
|
||||
46
CODE_OF_CONDUCT.md
Normal file
46
CODE_OF_CONDUCT.md
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
# Code of Conduct
|
||||
|
||||
As contributors and maintainers of the Jazzband projects, and in the interest of
|
||||
fostering an open and welcoming community, we pledge to respect all people who
|
||||
contribute through reporting issues, posting feature requests, updating documentation,
|
||||
submitting pull requests or patches, and other activities.
|
||||
|
||||
We are committed to making participation in the Jazzband a harassment-free experience
|
||||
for everyone, regardless of the level of experience, gender, gender identity and
|
||||
expression, sexual orientation, disability, personal appearance, body size, race,
|
||||
ethnicity, age, religion, or nationality.
|
||||
|
||||
Examples of unacceptable behavior by participants include:
|
||||
|
||||
- The use of sexualized language or imagery
|
||||
- Personal attacks
|
||||
- Trolling or insulting/derogatory comments
|
||||
- Public or private harassment
|
||||
- Publishing other's private information, such as physical or electronic addresses,
|
||||
without explicit permission
|
||||
- Other unethical or unprofessional conduct
|
||||
|
||||
The Jazzband roadies have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are not
|
||||
aligned to this Code of Conduct, or to ban temporarily or permanently any contributor
|
||||
for other behaviors that they deem inappropriate, threatening, offensive, or harmful.
|
||||
|
||||
By adopting this Code of Conduct, the roadies commit themselves to fairly and
|
||||
consistently applying these principles to every aspect of managing the jazzband
|
||||
projects. Roadies who do not follow or enforce the Code of Conduct may be permanently
|
||||
removed from the Jazzband roadies.
|
||||
|
||||
This code of conduct applies both within project spaces and in public spaces when an
|
||||
individual is representing the project or its community.
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by
|
||||
contacting the roadies at `roadies@jazzband.co`. All complaints will be reviewed and
|
||||
investigated and will result in a response that is deemed necessary and appropriate to
|
||||
the circumstances. Roadies are obligated to maintain confidentiality with regard to the
|
||||
reporter of an incident.
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version
|
||||
1.3.0, available at [https://contributor-covenant.org/version/1/3/0/][version]
|
||||
|
||||
[homepage]: https://contributor-covenant.org
|
||||
[version]: https://contributor-covenant.org/version/1/3/0/
|
||||
3
CONTRIBUTING.md
Normal file
3
CONTRIBUTING.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
[](https://jazzband.co/)
|
||||
|
||||
This is a [Jazzband](https://jazzband.co/) project. By contributing you agree to abide by the [Contributor Code of Conduct](https://jazzband.co/about/conduct) and follow the [guidelines](https://jazzband.co/about/guidelines).
|
||||
2
LICENSE
2
LICENSE
|
|
@ -1,6 +1,6 @@
|
|||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2015 Jan-Jelle Kester
|
||||
Copyright (c) 2013-2020 Jan-Jelle Kester
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
this software and associated documentation files (the "Software"), to deal in
|
||||
|
|
|
|||
24
MANIFEST
24
MANIFEST
|
|
@ -1,24 +0,0 @@
|
|||
# file GENERATED by distutils, do NOT edit
|
||||
setup.py
|
||||
src/auditlog/__init__.py
|
||||
src/auditlog/admin.py
|
||||
src/auditlog/apps.py
|
||||
src/auditlog/compat.py
|
||||
src/auditlog/diff.py
|
||||
src/auditlog/filters.py
|
||||
src/auditlog/middleware.py
|
||||
src/auditlog/mixins.py
|
||||
src/auditlog/models.py
|
||||
src/auditlog/receivers.py
|
||||
src/auditlog/registry.py
|
||||
src/auditlog/management/__init__.py
|
||||
src/auditlog/management/commands/__init__.py
|
||||
src/auditlog/management/commands/auditlogflush.py
|
||||
src/auditlog/migrations/0001_initial.py
|
||||
src/auditlog/migrations/0002_auto_support_long_primary_keys.py
|
||||
src/auditlog/migrations/0003_logentry_remote_addr.py
|
||||
src/auditlog/migrations/0004_logentry_detailed_object_repr.py
|
||||
src/auditlog/migrations/0005_logentry_additional_data_verbose_name.py
|
||||
src/auditlog/migrations/0006_object_pk_index.py
|
||||
src/auditlog/migrations/0007_object_pk_type.py
|
||||
src/auditlog/migrations/__init__.py
|
||||
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}'
|
||||
34
README.md
34
README.md
|
|
@ -1,22 +1,27 @@
|
|||
django-auditlog
|
||||
===============
|
||||
|
||||
[](https://travis-ci.org/jjkester/django-auditlog)
|
||||
[](http://django-auditlog.readthedocs.org/en/latest/?badge=latest)
|
||||
[](https://jazzband.co/)
|
||||
[](https://github.com/jazzband/django-auditlog/actions)
|
||||
[](https://django-auditlog.readthedocs.org/en/latest/?badge=latest)
|
||||
[](https://codecov.io/gh/jazzband/django-auditlog)
|
||||
[](https://pypi.python.org/pypi/django-auditlog)
|
||||
[](https://pypi.python.org/pypi/django-auditlog)
|
||||
|
||||
**Please remember that this app is still in development.**
|
||||
**Test this app before deploying it in production environments.**
|
||||
**Migrate 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.
|
||||
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.
|
||||
|
||||
Auditlog is created out of the need for a simple Django app that logs changes to models along with the user who made the changes (later referred to as actor). Existing solutions seemed to offer a type of version control, which was found excessive and expensive in terms of database storage and performance.
|
||||
|
||||
The core idea of Auditlog is similar to the log from Django’s admin. Unlike the log from Django’s admin (```django.contrib.admin```) Auditlog is much more flexible. Also, Auditlog saves a summary of the changes in JSON format, so changes can be tracked easily.
|
||||
The core idea of Auditlog is similar to the log from Django's admin. Unlike the log from Django's admin (```django.contrib.admin```) Auditlog is much more flexible. Also, Auditlog saves a summary of the changes in JSON format, so changes can be tracked easily.
|
||||
|
||||
Documentation
|
||||
-------------
|
||||
|
||||
The documentation for ```django-auditlog``` can be found on http://django-auditlog.readthedocs.org. The source files are available in the ```docs``` folder.
|
||||
The documentation for ```django-auditlog``` can be found on https://django-auditlog.readthedocs.org. The source files are available in the ```docs``` folder.
|
||||
|
||||
License
|
||||
-------
|
||||
|
|
@ -31,11 +36,10 @@ If you have great ideas for Auditlog, or if you like to improve something, feel
|
|||
Releases
|
||||
--------
|
||||
|
||||
1. Make sure all tests on `master` are green.
|
||||
2. Create a new branch `vX.Y.Z` from master for that specific release.
|
||||
3. Bump versions in `setup.py` and `docs/source/conf.py` (docs have 2 places where the versions need to be changed!)
|
||||
4. Pull request `vX.Y.Z` -> `stable`. Merging policy is very strict. This triggers a new release.
|
||||
5. Pull request `stable` -> `master`. Now everything is back in sync.
|
||||
|
||||
Opening a pull request from `master` directly to `stable` is discouraged as `master` may be updated while the PR is open, thus changing the contents of the release.
|
||||
|
||||
1. Make sure all tests on `master` are green
|
||||
2. Create a new branch `vX.Y.Z` from master for that specific release
|
||||
3. Update the CHANGELOG release date
|
||||
4. Pull request `vX.Y.Z` -> `master`
|
||||
5. As a project lead, once the PR is merged, create and push a tag `vX.Y.Z`: this will trigger the release build and a notification will be sent from Jazzband of the availability of two packages (tgz and wheel)
|
||||
6. Test the install
|
||||
7. Publish the release to PyPI
|
||||
|
|
|
|||
24
auditlog/__init__.py
Normal file
24
auditlog/__init__.py
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from importlib.metadata import version
|
||||
|
||||
from django.apps import apps as django_apps
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
|
||||
__version__ = version("django-auditlog")
|
||||
|
||||
|
||||
def get_logentry_model():
|
||||
model_string = getattr(settings, "AUDITLOG_LOGENTRY_MODEL", "auditlog.LogEntry")
|
||||
try:
|
||||
return django_apps.get_model(model_string, require_ready=False)
|
||||
except ValueError:
|
||||
raise ImproperlyConfigured(
|
||||
"AUDITLOG_LOGENTRY_MODEL must be of the form 'app_label.model_name'"
|
||||
)
|
||||
except LookupError:
|
||||
raise ImproperlyConfigured(
|
||||
"AUDITLOG_LOGENTRY_MODEL refers to model '%s' that has not been installed"
|
||||
% model_string
|
||||
)
|
||||
62
auditlog/admin.py
Normal file
62
auditlog/admin.py
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
from functools import cached_property
|
||||
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from auditlog import get_logentry_model
|
||||
from auditlog.filters import CIDFilter, ResourceTypeFilter
|
||||
from auditlog.mixins import LogEntryAdminMixin
|
||||
|
||||
LogEntry = get_logentry_model()
|
||||
|
||||
|
||||
@admin.register(LogEntry)
|
||||
class LogEntryAdmin(admin.ModelAdmin, LogEntryAdminMixin):
|
||||
date_hierarchy = "timestamp"
|
||||
list_select_related = ["content_type", "actor"]
|
||||
list_display = [
|
||||
"created",
|
||||
"resource_url",
|
||||
"action",
|
||||
"msg_short",
|
||||
"user_url",
|
||||
"cid_url",
|
||||
]
|
||||
search_fields = [
|
||||
"timestamp",
|
||||
"object_repr",
|
||||
"changes",
|
||||
"actor__first_name",
|
||||
"actor__last_name",
|
||||
f"actor__{get_user_model().USERNAME_FIELD}",
|
||||
]
|
||||
list_filter = ["action", ResourceTypeFilter, CIDFilter]
|
||||
readonly_fields = ["created", "resource_url", "action", "user_url", "msg"]
|
||||
fieldsets = [
|
||||
(None, {"fields": ["created", "user_url", "resource_url", "cid"]}),
|
||||
(_("Changes"), {"fields": ["action", "msg"]}),
|
||||
]
|
||||
|
||||
def has_add_permission(self, request):
|
||||
return False
|
||||
|
||||
def has_change_permission(self, request, obj=None):
|
||||
return False
|
||||
|
||||
@cached_property
|
||||
def _own_url_names(self):
|
||||
return [pattern.name for pattern in self.urls if pattern.name]
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
if (
|
||||
request.resolver_match
|
||||
and request.resolver_match.url_name not in self._own_url_names
|
||||
):
|
||||
# only allow cascade delete to satisfy delete_related flag
|
||||
return super().has_delete_permission(request, obj)
|
||||
return False
|
||||
|
||||
def get_queryset(self, request):
|
||||
self.request = request
|
||||
return super().get_queryset(request=request)
|
||||
17
auditlog/apps.py
Normal file
17
auditlog/apps.py
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
from django.apps import AppConfig
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
class AuditlogConfig(AppConfig):
|
||||
name = "auditlog"
|
||||
verbose_name = _("Audit log")
|
||||
default_auto_field = "django.db.models.AutoField"
|
||||
|
||||
def ready(self):
|
||||
from auditlog.registry import auditlog
|
||||
|
||||
auditlog.register_from_settings()
|
||||
|
||||
from auditlog import models
|
||||
|
||||
models.changes_func = models._changes_func()
|
||||
70
auditlog/cid.py
Normal file
70
auditlog/cid.py
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
from contextvars import ContextVar
|
||||
|
||||
from django.conf import settings
|
||||
from django.http import HttpRequest
|
||||
from django.utils.module_loading import import_string
|
||||
|
||||
correlation_id = ContextVar("auditlog_correlation_id", default=None)
|
||||
|
||||
|
||||
def set_cid(request: HttpRequest | None = None) -> None:
|
||||
"""
|
||||
A function to read the cid from a request.
|
||||
If the header is not in the request, then we set it to `None`.
|
||||
|
||||
Note: we look for the value of `AUDITLOG_CID_HEADER` in `request.headers` and in `request.META`.
|
||||
|
||||
This function doesn't do anything if the user is supplying their own `AUDITLOG_CID_GETTER`.
|
||||
|
||||
:param request: The request to get the cid from.
|
||||
:return: None
|
||||
"""
|
||||
if settings.AUDITLOG_CID_GETTER:
|
||||
return
|
||||
|
||||
cid = None
|
||||
header = settings.AUDITLOG_CID_HEADER
|
||||
|
||||
if header and request:
|
||||
if header in request.headers:
|
||||
cid = request.headers.get(header)
|
||||
elif header in request.META:
|
||||
cid = request.META.get(header)
|
||||
|
||||
# Ideally, this line should be nested inside the if statement.
|
||||
# However, because the tests do not run requests in multiple threads,
|
||||
# we have to always set the value of the cid,
|
||||
# even if the request does not have the header present,
|
||||
# in which case it will be set to None
|
||||
correlation_id.set(cid)
|
||||
|
||||
|
||||
def _get_cid() -> str | None:
|
||||
return correlation_id.get()
|
||||
|
||||
|
||||
def get_cid() -> str | None:
|
||||
"""
|
||||
Calls the cid getter function based on `settings.AUDITLOG_CID_GETTER`
|
||||
|
||||
If the setting value is:
|
||||
|
||||
* None: then it calls the default getter (which retrieves the value set in `set_cid`)
|
||||
* callable: then it calls the function
|
||||
* type(str): then it imports the function and then call it
|
||||
|
||||
The result is then returned to the caller.
|
||||
|
||||
If your custom getter does not depend on `set_header()`,
|
||||
then we recommend setting `settings.AUDITLOG_CID_GETTER` to `None`.
|
||||
|
||||
:return: The correlation ID
|
||||
"""
|
||||
method = settings.AUDITLOG_CID_GETTER
|
||||
if not method:
|
||||
return _get_cid()
|
||||
|
||||
if callable(method):
|
||||
return method()
|
||||
|
||||
return import_string(method)()
|
||||
78
auditlog/conf.py
Normal file
78
auditlog/conf.py
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
from django.conf import settings
|
||||
|
||||
# Register all models when set to True
|
||||
settings.AUDITLOG_INCLUDE_ALL_MODELS = getattr(
|
||||
settings, "AUDITLOG_INCLUDE_ALL_MODELS", False
|
||||
)
|
||||
|
||||
# Exclude models in registration process
|
||||
# It will be considered when `AUDITLOG_INCLUDE_ALL_MODELS` is True
|
||||
settings.AUDITLOG_EXCLUDE_TRACKING_MODELS = getattr(
|
||||
settings, "AUDITLOG_EXCLUDE_TRACKING_MODELS", ()
|
||||
)
|
||||
|
||||
# Register models and define their logging behaviour
|
||||
settings.AUDITLOG_INCLUDE_TRACKING_MODELS = getattr(
|
||||
settings, "AUDITLOG_INCLUDE_TRACKING_MODELS", ()
|
||||
)
|
||||
|
||||
# Exclude named fields across all models
|
||||
settings.AUDITLOG_EXCLUDE_TRACKING_FIELDS = getattr(
|
||||
settings, "AUDITLOG_EXCLUDE_TRACKING_FIELDS", ()
|
||||
)
|
||||
|
||||
# Mask named fields across all models
|
||||
settings.AUDITLOG_MASK_TRACKING_FIELDS = getattr(
|
||||
settings, "AUDITLOG_MASK_TRACKING_FIELDS", ()
|
||||
)
|
||||
|
||||
# Disable on raw save to avoid logging imports and similar
|
||||
settings.AUDITLOG_DISABLE_ON_RAW_SAVE = getattr(
|
||||
settings, "AUDITLOG_DISABLE_ON_RAW_SAVE", False
|
||||
)
|
||||
|
||||
# CID
|
||||
|
||||
settings.AUDITLOG_CID_HEADER = getattr(
|
||||
settings, "AUDITLOG_CID_HEADER", "x-correlation-id"
|
||||
)
|
||||
settings.AUDITLOG_CID_GETTER = getattr(settings, "AUDITLOG_CID_GETTER", None)
|
||||
|
||||
# migration
|
||||
settings.AUDITLOG_TWO_STEP_MIGRATION = getattr(
|
||||
settings, "AUDITLOG_TWO_STEP_MIGRATION", False
|
||||
)
|
||||
settings.AUDITLOG_USE_TEXT_CHANGES_IF_JSON_IS_NOT_PRESENT = getattr(
|
||||
settings, "AUDITLOG_USE_TEXT_CHANGES_IF_JSON_IS_NOT_PRESENT", False
|
||||
)
|
||||
|
||||
# Disable remote_addr field in database
|
||||
settings.AUDITLOG_DISABLE_REMOTE_ADDR = getattr(
|
||||
settings, "AUDITLOG_DISABLE_REMOTE_ADDR", False
|
||||
)
|
||||
|
||||
# Number of characters at which changes_display_dict property should be shown
|
||||
settings.AUDITLOG_CHANGE_DISPLAY_TRUNCATE_LENGTH = getattr(
|
||||
settings, "AUDITLOG_CHANGE_DISPLAY_TRUNCATE_LENGTH", 140
|
||||
)
|
||||
|
||||
# Use pure JSON for changes field
|
||||
settings.AUDITLOG_STORE_JSON_CHANGES = getattr(
|
||||
settings, "AUDITLOG_STORE_JSON_CHANGES", False
|
||||
)
|
||||
|
||||
settings.AUDITLOG_MASK_CALLABLE = getattr(settings, "AUDITLOG_MASK_CALLABLE", None)
|
||||
|
||||
settings.AUDITLOG_LOGENTRY_MODEL = getattr(
|
||||
settings, "AUDITLOG_LOGENTRY_MODEL", "auditlog.LogEntry"
|
||||
)
|
||||
|
||||
# Use base model managers instead of default model managers
|
||||
settings.AUDITLOG_USE_BASE_MANAGER = getattr(
|
||||
settings, "AUDITLOG_USE_BASE_MANAGER", False
|
||||
)
|
||||
|
||||
# Use string representation of referenced object in foreign key changes instead of its primary key
|
||||
settings.AUDITLOG_USE_FK_STRING_REPRESENTATION = getattr(
|
||||
settings, "AUDITLOG_USE_FK_STRING_REPRESENTATION", False
|
||||
)
|
||||
107
auditlog/context.py
Normal file
107
auditlog/context.py
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import contextlib
|
||||
import time
|
||||
from contextvars import ContextVar
|
||||
from functools import partial
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db.models.signals import pre_save
|
||||
|
||||
from auditlog import get_logentry_model
|
||||
|
||||
auditlog_value = ContextVar("auditlog_value")
|
||||
auditlog_disabled = ContextVar("auditlog_disabled", default=False)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def set_actor(actor, remote_addr=None, remote_port=None):
|
||||
context_data = {
|
||||
"actor": actor,
|
||||
"remote_addr": remote_addr,
|
||||
"remote_port": remote_port,
|
||||
}
|
||||
return call_context_manager(context_data)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def set_extra_data(context_data):
|
||||
return call_context_manager(context_data)
|
||||
|
||||
|
||||
def call_context_manager(context_data):
|
||||
"""Connect a signal receiver with current user attached."""
|
||||
LogEntry = get_logentry_model()
|
||||
# Initialize thread local storage
|
||||
context_data["signal_duid"] = ("set_actor", time.time())
|
||||
auditlog_value.set(context_data)
|
||||
|
||||
# Connect signal for automatic logging
|
||||
set_extra_data = partial(
|
||||
_set_extra_data,
|
||||
signal_duid=context_data["signal_duid"],
|
||||
)
|
||||
pre_save.connect(
|
||||
set_extra_data,
|
||||
sender=LogEntry,
|
||||
dispatch_uid=context_data["signal_duid"],
|
||||
weak=False,
|
||||
)
|
||||
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
try:
|
||||
auditlog = auditlog_value.get()
|
||||
except LookupError:
|
||||
pass
|
||||
else:
|
||||
pre_save.disconnect(sender=LogEntry, dispatch_uid=auditlog["signal_duid"])
|
||||
|
||||
|
||||
def _set_actor(auditlog, instance, sender):
|
||||
LogEntry = get_logentry_model()
|
||||
auth_user_model = get_user_model()
|
||||
if "actor" in auditlog:
|
||||
actor = auditlog.get("actor")
|
||||
if (
|
||||
sender == LogEntry
|
||||
and isinstance(actor, auth_user_model)
|
||||
and instance.actor is None
|
||||
):
|
||||
instance.actor = actor
|
||||
instance.actor_email = getattr(actor, "email", None)
|
||||
|
||||
|
||||
def _set_extra_data(sender, instance, signal_duid, **kwargs):
|
||||
"""Signal receiver with extra 'user' and 'signal_duid' kwargs.
|
||||
|
||||
This function becomes a valid signal receiver when it is curried with the actor and a dispatch id.
|
||||
"""
|
||||
LogEntry = get_logentry_model()
|
||||
try:
|
||||
auditlog = auditlog_value.get()
|
||||
except LookupError:
|
||||
pass
|
||||
else:
|
||||
if signal_duid != auditlog["signal_duid"]:
|
||||
return
|
||||
|
||||
_set_actor(auditlog, instance, sender)
|
||||
|
||||
for key in auditlog:
|
||||
if key != "actor" and hasattr(LogEntry, key):
|
||||
if callable(auditlog[key]):
|
||||
setattr(instance, key, auditlog[key]())
|
||||
else:
|
||||
setattr(instance, key, auditlog[key])
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def disable_auditlog():
|
||||
token = auditlog_disabled.set(True)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
try:
|
||||
auditlog_disabled.reset(token)
|
||||
except LookupError:
|
||||
pass
|
||||
283
auditlog/diff.py
Normal file
283
auditlog/diff.py
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
import json
|
||||
from collections.abc import Callable
|
||||
from datetime import timezone
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist
|
||||
from django.db.models import NOT_PROVIDED, DateTimeField, ForeignKey, JSONField, Model
|
||||
from django.utils import timezone as django_timezone
|
||||
from django.utils.encoding import smart_str
|
||||
from django.utils.module_loading import import_string
|
||||
|
||||
from auditlog import get_logentry_model
|
||||
|
||||
|
||||
def track_field(field):
|
||||
"""
|
||||
Returns whether the given field should be tracked by Auditlog.
|
||||
|
||||
Untracked fields are many-to-many relations and relations to the Auditlog LogEntry model.
|
||||
|
||||
:param field: The field to check.
|
||||
:type field: Field
|
||||
:return: Whether the given field should be tracked.
|
||||
:rtype: bool
|
||||
"""
|
||||
|
||||
# Do not track many to many relations
|
||||
if field.many_to_many:
|
||||
return False
|
||||
|
||||
# Do not track relations to LogEntry
|
||||
if (
|
||||
getattr(field, "remote_field", None) is not None
|
||||
and field.remote_field.model == get_logentry_model()
|
||||
):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def get_fields_in_model(instance):
|
||||
"""
|
||||
Returns the list of fields in the given model instance. Checks whether to use the official
|
||||
_meta API or use the raw data. This method excludes many to many fields.
|
||||
|
||||
:param instance: The model instance to get the fields for
|
||||
:type instance: Model
|
||||
:return: The list of fields for the given model (instance)
|
||||
:rtype: list
|
||||
"""
|
||||
assert isinstance(instance, Model)
|
||||
|
||||
return [f for f in instance._meta.get_fields() if track_field(f)]
|
||||
|
||||
|
||||
def get_field_value(obj, field, use_json_for_changes=False):
|
||||
"""
|
||||
Gets the value of a given model instance field.
|
||||
|
||||
:param obj: The model instance.
|
||||
:type obj: Model
|
||||
:param field: The field you want to find the value of.
|
||||
:type field: Any
|
||||
:return: The value of the field as a string.
|
||||
:rtype: str
|
||||
"""
|
||||
|
||||
def get_default_value():
|
||||
"""
|
||||
Attempts to get the default value for a field from the model's field definition.
|
||||
|
||||
:return: The default value of the field or None
|
||||
"""
|
||||
try:
|
||||
model_field = obj._meta.get_field(field.name)
|
||||
default = model_field.default
|
||||
except (AttributeError, FieldDoesNotExist):
|
||||
default = NOT_PROVIDED
|
||||
|
||||
if default is NOT_PROVIDED:
|
||||
default = None
|
||||
elif callable(default):
|
||||
default = default()
|
||||
|
||||
return smart_str(default) if not use_json_for_changes else default
|
||||
|
||||
try:
|
||||
if isinstance(field, DateTimeField):
|
||||
# DateTimeFields are timezone-aware, so we need to convert the field
|
||||
# to its naive form before we can accurately compare them for changes.
|
||||
value = getattr(obj, field.name)
|
||||
try:
|
||||
value = field.to_python(value)
|
||||
except TypeError:
|
||||
return value
|
||||
if (
|
||||
value is not None
|
||||
and settings.USE_TZ
|
||||
and not django_timezone.is_naive(value)
|
||||
):
|
||||
value = django_timezone.make_naive(value, timezone=timezone.utc)
|
||||
elif isinstance(field, JSONField):
|
||||
value = field.to_python(getattr(obj, field.name))
|
||||
if not use_json_for_changes:
|
||||
try:
|
||||
value = json.dumps(value, sort_keys=True, cls=field.encoder)
|
||||
except TypeError:
|
||||
pass
|
||||
elif (
|
||||
not settings.AUDITLOG_USE_FK_STRING_REPRESENTATION
|
||||
and (field.one_to_one or field.many_to_one)
|
||||
and hasattr(field, "rel_class")
|
||||
):
|
||||
value = smart_str(getattr(obj, field.get_attname()), strings_only=True)
|
||||
else:
|
||||
value = getattr(obj, field.name)
|
||||
if not use_json_for_changes:
|
||||
value = smart_str(value)
|
||||
if type(value).__name__ == "__proxy__":
|
||||
value = str(value)
|
||||
except (ObjectDoesNotExist, AttributeError):
|
||||
return get_default_value()
|
||||
|
||||
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:
|
||||
"""
|
||||
Masks the first half of the input string to remove sensitive data.
|
||||
|
||||
:param value: The value to mask.
|
||||
:type value: str
|
||||
:return: The masked version of the string.
|
||||
:rtype: str
|
||||
"""
|
||||
mask_limit = int(len(value) / 2)
|
||||
return "*" * mask_limit + value[mask_limit:]
|
||||
|
||||
|
||||
def model_instance_diff(
|
||||
old: Model | None,
|
||||
new: Model | None,
|
||||
fields_to_check=None,
|
||||
use_json_for_changes=False,
|
||||
):
|
||||
"""
|
||||
Calculates the differences between two model instances. One of the instances may be ``None``
|
||||
(i.e., a newly created model or deleted model). This will cause all fields with a value to have
|
||||
changed (from ``None``).
|
||||
|
||||
:param old: The old state of the model instance.
|
||||
:type old: Model
|
||||
:param new: The new state of the model instance.
|
||||
:type new: Model
|
||||
:param fields_to_check: An iterable of the field names to restrict the diff to, while ignoring the rest of
|
||||
the model's fields. This is used to pass the `update_fields` kwarg from the model's `save` method.
|
||||
:param use_json_for_changes: whether or not to use a JSON for changes
|
||||
(see settings.AUDITLOG_STORE_JSON_CHANGES)
|
||||
:type fields_to_check: Iterable
|
||||
:return: A dictionary with the names of the changed fields as keys and a two tuple of the old and new
|
||||
field values as value.
|
||||
:rtype: dict
|
||||
"""
|
||||
from auditlog.registry import auditlog
|
||||
|
||||
if not (old is None or isinstance(old, Model)):
|
||||
raise TypeError("The supplied old instance is not a valid model instance.")
|
||||
if not (new is None or isinstance(new, Model)):
|
||||
raise TypeError("The supplied new instance is not a valid model instance.")
|
||||
|
||||
diff = {}
|
||||
|
||||
if old is not None and new is not None:
|
||||
fields = set(old._meta.fields + new._meta.fields)
|
||||
model_fields = auditlog.get_model_fields(new._meta.model)
|
||||
elif old is not None:
|
||||
fields = set(get_fields_in_model(old))
|
||||
model_fields = auditlog.get_model_fields(old._meta.model)
|
||||
elif new is not None:
|
||||
fields = set(get_fields_in_model(new))
|
||||
model_fields = auditlog.get_model_fields(new._meta.model)
|
||||
else:
|
||||
fields = set()
|
||||
model_fields = None
|
||||
|
||||
if fields_to_check:
|
||||
fields = {
|
||||
field
|
||||
for field in fields
|
||||
if (
|
||||
(isinstance(field, ForeignKey) and field.attname in fields_to_check)
|
||||
or (field.name in fields_to_check)
|
||||
)
|
||||
}
|
||||
|
||||
# Check if fields must be filtered
|
||||
if (
|
||||
model_fields
|
||||
and (model_fields["include_fields"] or model_fields["exclude_fields"])
|
||||
and fields
|
||||
):
|
||||
filtered_fields = []
|
||||
if model_fields["include_fields"]:
|
||||
filtered_fields = [
|
||||
field
|
||||
for field in fields
|
||||
if field.name in model_fields["include_fields"]
|
||||
]
|
||||
else:
|
||||
filtered_fields = fields
|
||||
if model_fields["exclude_fields"]:
|
||||
filtered_fields = [
|
||||
field
|
||||
for field in filtered_fields
|
||||
if field.name not in model_fields["exclude_fields"]
|
||||
]
|
||||
fields = filtered_fields
|
||||
|
||||
for field in fields:
|
||||
old_value = get_field_value(old, field, use_json_for_changes)
|
||||
new_value = get_field_value(new, field, use_json_for_changes)
|
||||
|
||||
if old_value != new_value:
|
||||
if model_fields and field.name in model_fields["mask_fields"]:
|
||||
mask_func = get_mask_function(model_fields.get("mask_callable"))
|
||||
|
||||
diff[field.name] = (
|
||||
mask_func(smart_str(old_value)),
|
||||
mask_func(smart_str(new_value)),
|
||||
)
|
||||
else:
|
||||
if not use_json_for_changes:
|
||||
diff[field.name] = (smart_str(old_value), smart_str(new_value))
|
||||
else:
|
||||
# TODO: should we handle the case where the value is a django Model specifically?
|
||||
# for example, could create a list of ids for ManyToMany fields
|
||||
|
||||
# this maintains the behavior of the original code
|
||||
if not is_primitive(old_value):
|
||||
old_value = smart_str(old_value)
|
||||
if not is_primitive(new_value):
|
||||
new_value = smart_str(new_value)
|
||||
diff[field.name] = (old_value, new_value)
|
||||
|
||||
if len(diff) == 0:
|
||||
diff = None
|
||||
|
||||
return diff
|
||||
33
auditlog/filters.py
Normal file
33
auditlog/filters.py
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
from django.contrib.admin import SimpleListFilter
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
class ResourceTypeFilter(SimpleListFilter):
|
||||
title = _("Resource Type")
|
||||
parameter_name = "resource_type"
|
||||
|
||||
def lookups(self, request, model_admin):
|
||||
qs = model_admin.get_queryset(request)
|
||||
types = qs.values_list("content_type_id", "content_type__model")
|
||||
return list(types.order_by("content_type__model").distinct())
|
||||
|
||||
def queryset(self, request, queryset):
|
||||
if self.value() is None:
|
||||
return queryset
|
||||
return queryset.filter(content_type_id=self.value())
|
||||
|
||||
|
||||
class CIDFilter(SimpleListFilter):
|
||||
title = _("Correlation ID")
|
||||
parameter_name = "cid"
|
||||
|
||||
def lookups(self, request, model_admin):
|
||||
return []
|
||||
|
||||
def has_output(self):
|
||||
return True
|
||||
|
||||
def queryset(self, request, queryset):
|
||||
if self.value() is None:
|
||||
return queryset
|
||||
return queryset.filter(cid=self.value())
|
||||
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] "항목"
|
||||
96
auditlog/management/commands/auditlogflush.py
Normal file
96
auditlog/management/commands/auditlogflush.py
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import datetime
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import connection
|
||||
|
||||
from auditlog import get_logentry_model
|
||||
|
||||
LogEntry = get_logentry_model()
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Deletes all log entries from the database."
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"-y",
|
||||
"--yes",
|
||||
action="store_true",
|
||||
default=None,
|
||||
help="Continue without asking confirmation.",
|
||||
dest="yes",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-b",
|
||||
"--before-date",
|
||||
default=None,
|
||||
help="Flush all entries with a timestamp before a given date (ISO 8601).",
|
||||
dest="before_date",
|
||||
type=datetime.date.fromisoformat,
|
||||
)
|
||||
parser.add_argument(
|
||||
"-t",
|
||||
"--truncate",
|
||||
action="store_true",
|
||||
default=None,
|
||||
help="Truncate log entry table.",
|
||||
dest="truncate",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
answer = options["yes"]
|
||||
truncate = options["truncate"]
|
||||
before = options["before_date"]
|
||||
if truncate and before:
|
||||
self.stdout.write(
|
||||
"Truncate deletes all log entries and can not be passed with before-date."
|
||||
)
|
||||
return
|
||||
if answer is None:
|
||||
warning_message = (
|
||||
"This action will clear all log entries from the database."
|
||||
)
|
||||
if before is not None:
|
||||
warning_message = f"This action will clear all log entries before {before} from the database."
|
||||
self.stdout.write(warning_message)
|
||||
response = (
|
||||
input("Are you sure you want to continue? [y/N]: ").lower().strip()
|
||||
)
|
||||
answer = response == "y"
|
||||
|
||||
if not answer:
|
||||
self.stdout.write("Aborted.")
|
||||
return
|
||||
|
||||
if not truncate:
|
||||
entries = LogEntry.objects.all()
|
||||
if before is not None:
|
||||
entries = entries.filter(timestamp__date__lt=before)
|
||||
count, _ = entries.delete()
|
||||
self.stdout.write("Deleted %d objects." % count)
|
||||
else:
|
||||
database_vendor = connection.vendor
|
||||
database_display_name = connection.display_name
|
||||
table_name = LogEntry._meta.db_table
|
||||
if not TruncateQuery.support_truncate_statement(database_vendor):
|
||||
self.stdout.write(
|
||||
"Database %s does not support truncate statement."
|
||||
% database_display_name
|
||||
)
|
||||
return
|
||||
with connection.cursor() as cursor:
|
||||
query = TruncateQuery.to_sql(table_name)
|
||||
cursor.execute(query)
|
||||
self.stdout.write("Truncated log entry table.")
|
||||
|
||||
|
||||
class TruncateQuery:
|
||||
SUPPORTED_VENDORS = ("postgresql", "mysql", "oracle", "microsoft")
|
||||
|
||||
@classmethod
|
||||
def support_truncate_statement(cls, database_vendor) -> bool:
|
||||
return database_vendor in cls.SUPPORTED_VENDORS
|
||||
|
||||
@staticmethod
|
||||
def to_sql(table_name) -> str:
|
||||
return f"TRUNCATE TABLE {table_name};"
|
||||
144
auditlog/management/commands/auditlogmigratejson.py
Normal file
144
auditlog/management/commands/auditlogmigratejson.py
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
from math import ceil
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.management import CommandError, CommandParser
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from auditlog import get_logentry_model
|
||||
|
||||
LogEntry = get_logentry_model()
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Migrates changes from changes_text to json changes."
|
||||
requires_migrations_checks = True
|
||||
|
||||
def add_arguments(self, parser: CommandParser):
|
||||
group = parser.add_argument_group()
|
||||
group.add_argument(
|
||||
"--check",
|
||||
action="store_true",
|
||||
help="Just check the status of the migration",
|
||||
dest="check",
|
||||
)
|
||||
group.add_argument(
|
||||
"-d",
|
||||
"--database",
|
||||
default=None,
|
||||
metavar="The database engine",
|
||||
help="If provided, the script will use native db operations. "
|
||||
"Otherwise, it will use LogEntry.objects.bulk_update",
|
||||
dest="db",
|
||||
type=str,
|
||||
choices=["postgres", "mysql", "oracle"],
|
||||
)
|
||||
group.add_argument(
|
||||
"-b",
|
||||
"--batch-size",
|
||||
default=500,
|
||||
help="Split the migration into multiple batches. If 0, then no batching will be done. "
|
||||
"When passing a -d/database, the batch value will be ignored.",
|
||||
dest="batch_size",
|
||||
type=int,
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
database = options["db"]
|
||||
batch_size = options["batch_size"]
|
||||
check = options["check"]
|
||||
|
||||
if (not self.check_logs()) or check:
|
||||
return
|
||||
|
||||
if database:
|
||||
result = self.migrate_using_sql(database)
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"Updated {result} records using native database operations."
|
||||
)
|
||||
)
|
||||
else:
|
||||
result = self.migrate_using_django(batch_size)
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f"Updated {result} records using django operations.")
|
||||
)
|
||||
|
||||
self.check_logs()
|
||||
|
||||
def check_logs(self):
|
||||
count = self.get_logs().count()
|
||||
if count:
|
||||
self.stdout.write(f"There are {count} records that needs migration.")
|
||||
return True
|
||||
|
||||
self.stdout.write(self.style.SUCCESS("All records have been migrated."))
|
||||
if settings.AUDITLOG_USE_TEXT_CHANGES_IF_JSON_IS_NOT_PRESENT:
|
||||
var_msg = self.style.WARNING(
|
||||
"AUDITLOG_USE_TEXT_CHANGES_IF_JSON_IS_NOT_PRESENT"
|
||||
)
|
||||
self.stdout.write(f"You can now set {var_msg} to False.")
|
||||
|
||||
return False
|
||||
|
||||
def get_logs(self):
|
||||
return LogEntry.objects.filter(
|
||||
changes_text__isnull=False, changes__isnull=True
|
||||
).exclude(changes_text__exact="")
|
||||
|
||||
def migrate_using_django(self, batch_size):
|
||||
def _apply_django_migration(_logs) -> int:
|
||||
import json
|
||||
|
||||
updated = []
|
||||
errors = []
|
||||
for log in _logs:
|
||||
try:
|
||||
log.changes = json.loads(log.changes_text)
|
||||
except ValueError:
|
||||
errors.append(log.id)
|
||||
else:
|
||||
updated.append(log)
|
||||
|
||||
LogEntry.objects.bulk_update(updated, fields=["changes"])
|
||||
if errors:
|
||||
self.stderr.write(
|
||||
self.style.ERROR(
|
||||
f"ValueError was raised while converting the logs with these ids into json."
|
||||
f"They where not be included in this migration batch."
|
||||
f"\n"
|
||||
f"{errors}"
|
||||
)
|
||||
)
|
||||
return len(updated)
|
||||
|
||||
logs = self.get_logs()
|
||||
|
||||
if not batch_size:
|
||||
return _apply_django_migration(logs)
|
||||
|
||||
total_updated = 0
|
||||
for _ in range(ceil(logs.count() / batch_size)):
|
||||
total_updated += _apply_django_migration(self.get_logs()[:batch_size])
|
||||
return total_updated
|
||||
|
||||
def migrate_using_sql(self, database):
|
||||
from django.db import connection
|
||||
|
||||
def postgres():
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(f"""
|
||||
UPDATE {LogEntry._meta.db_table}
|
||||
SET changes="changes_text"::jsonb
|
||||
WHERE changes_text IS NOT NULL
|
||||
AND changes_text <> ''
|
||||
AND changes IS NULL
|
||||
""")
|
||||
return cursor.cursor.rowcount
|
||||
|
||||
if database == "postgres":
|
||||
return postgres()
|
||||
|
||||
raise CommandError(
|
||||
f"Migrating the records using {database} is not implemented. "
|
||||
f"Run this management command without passing a -d/--database argument."
|
||||
)
|
||||
70
auditlog/middleware.py
Normal file
70
auditlog/middleware.py
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
from auditlog.cid import set_cid
|
||||
from auditlog.context import set_extra_data
|
||||
|
||||
|
||||
class AuditlogMiddleware:
|
||||
"""
|
||||
Middleware to couple the request's user to log items. This is accomplished by currying the
|
||||
signal receiver with the user from the request (or None if the user is not authenticated).
|
||||
"""
|
||||
|
||||
def __init__(self, get_response=None):
|
||||
self.get_response = get_response
|
||||
if not isinstance(settings.AUDITLOG_DISABLE_REMOTE_ADDR, bool):
|
||||
raise TypeError("Setting 'AUDITLOG_DISABLE_REMOTE_ADDR' must be a boolean")
|
||||
|
||||
@staticmethod
|
||||
def _get_remote_addr(request):
|
||||
if settings.AUDITLOG_DISABLE_REMOTE_ADDR:
|
||||
return None
|
||||
|
||||
# In case there is no proxy, return the original address
|
||||
if not request.headers.get("X-Forwarded-For"):
|
||||
return request.META.get("REMOTE_ADDR")
|
||||
|
||||
# In case of proxy, set 'original' address
|
||||
remote_addr: str = request.headers.get("X-Forwarded-For").split(",")[0]
|
||||
|
||||
# Remove port number from remote_addr
|
||||
if "." in remote_addr and ":" in remote_addr: # IPv4 with port (`x.x.x.x:x`)
|
||||
remote_addr = remote_addr.split(":")[0]
|
||||
elif "[" in remote_addr: # IPv6 with port (`[:::]:x`)
|
||||
remote_addr = remote_addr[1:].split("]")[0]
|
||||
|
||||
return remote_addr
|
||||
|
||||
@staticmethod
|
||||
def _get_remote_port(request) -> int | None:
|
||||
remote_port = request.headers.get("X-Forwarded-Port", "")
|
||||
|
||||
try:
|
||||
remote_port = int(remote_port)
|
||||
except ValueError:
|
||||
remote_port = None
|
||||
|
||||
return remote_port
|
||||
|
||||
@staticmethod
|
||||
def _get_actor(request):
|
||||
user = getattr(request, "user", None)
|
||||
if isinstance(user, get_user_model()) and user.is_authenticated:
|
||||
return user
|
||||
return None
|
||||
|
||||
def get_extra_data(self, request):
|
||||
context_data = {}
|
||||
context_data["remote_addr"] = self._get_remote_addr(request)
|
||||
context_data["remote_port"] = self._get_remote_port(request)
|
||||
|
||||
context_data["actor"] = self._get_actor(request)
|
||||
|
||||
return context_data
|
||||
|
||||
def __call__(self, request):
|
||||
set_cid(request)
|
||||
|
||||
with set_extra_data(context_data=self.get_extra_data(request)):
|
||||
return self.get_response(request)
|
||||
77
auditlog/migrations/0001_initial.py
Normal file
77
auditlog/migrations/0001_initial.py
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
("contenttypes", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="LogEntry",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
verbose_name="ID",
|
||||
serialize=False,
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
),
|
||||
),
|
||||
("object_pk", models.TextField(verbose_name="object pk")),
|
||||
(
|
||||
"object_id",
|
||||
models.PositiveIntegerField(
|
||||
db_index=True, null=True, verbose_name="object id", blank=True
|
||||
),
|
||||
),
|
||||
("object_repr", models.TextField(verbose_name="object representation")),
|
||||
(
|
||||
"action",
|
||||
models.PositiveSmallIntegerField(
|
||||
verbose_name="action",
|
||||
choices=[(0, "create"), (1, "update"), (2, "delete")],
|
||||
),
|
||||
),
|
||||
(
|
||||
"changes",
|
||||
models.TextField(verbose_name="change message", blank=True),
|
||||
),
|
||||
(
|
||||
"timestamp",
|
||||
models.DateTimeField(auto_now_add=True, verbose_name="timestamp"),
|
||||
),
|
||||
(
|
||||
"actor",
|
||||
models.ForeignKey(
|
||||
related_name="+",
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
verbose_name="actor",
|
||||
blank=True,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"content_type",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="+",
|
||||
verbose_name="content type",
|
||||
to="contenttypes.ContentType",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-timestamp"],
|
||||
"get_latest_by": "timestamp",
|
||||
"verbose_name": "log entry",
|
||||
"verbose_name_plural": "log entries",
|
||||
},
|
||||
bases=(models.Model,),
|
||||
),
|
||||
]
|
||||
17
auditlog/migrations/0002_auto_support_long_primary_keys.py
Normal file
17
auditlog/migrations/0002_auto_support_long_primary_keys.py
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("auditlog", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="logentry",
|
||||
name="object_id",
|
||||
field=models.BigIntegerField(
|
||||
db_index=True, null=True, verbose_name="object id", blank=True
|
||||
),
|
||||
),
|
||||
]
|
||||
17
auditlog/migrations/0003_logentry_remote_addr.py
Normal file
17
auditlog/migrations/0003_logentry_remote_addr.py
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("auditlog", "0002_auto_support_long_primary_keys"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="logentry",
|
||||
name="remote_addr",
|
||||
field=models.GenericIPAddressField(
|
||||
null=True, verbose_name="remote address", blank=True
|
||||
),
|
||||
),
|
||||
]
|
||||
15
auditlog/migrations/0004_logentry_detailed_object_repr.py
Normal file
15
auditlog/migrations/0004_logentry_detailed_object_repr.py
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("auditlog", "0003_logentry_remote_addr"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="logentry",
|
||||
name="additional_data",
|
||||
field=models.JSONField(null=True, blank=True),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("auditlog", "0004_logentry_detailed_object_repr"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="logentry",
|
||||
name="additional_data",
|
||||
field=models.JSONField(
|
||||
null=True, verbose_name="additional data", blank=True
|
||||
),
|
||||
),
|
||||
]
|
||||
17
auditlog/migrations/0006_object_pk_index.py
Normal file
17
auditlog/migrations/0006_object_pk_index.py
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("auditlog", "0005_logentry_additional_data_verbose_name"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="logentry",
|
||||
name="object_pk",
|
||||
field=models.CharField(
|
||||
verbose_name="object pk", max_length=255, db_index=True
|
||||
),
|
||||
),
|
||||
]
|
||||
17
auditlog/migrations/0007_object_pk_type.py
Normal file
17
auditlog/migrations/0007_object_pk_type.py
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("auditlog", "0006_object_pk_index"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="logentry",
|
||||
name="object_pk",
|
||||
field=models.CharField(
|
||||
verbose_name="object pk", max_length=255, db_index=True
|
||||
),
|
||||
),
|
||||
]
|
||||
19
auditlog/migrations/0008_action_index.py
Normal file
19
auditlog/migrations/0008_action_index.py
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("auditlog", "0007_object_pk_type"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="logentry",
|
||||
name="action",
|
||||
field=models.PositiveSmallIntegerField(
|
||||
choices=[(0, "create"), (1, "update"), (2, "delete")],
|
||||
db_index=True,
|
||||
verbose_name="action",
|
||||
),
|
||||
),
|
||||
]
|
||||
17
auditlog/migrations/0009_alter_logentry_additional_data.py
Normal file
17
auditlog/migrations/0009_alter_logentry_additional_data.py
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("auditlog", "0008_action_index"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="logentry",
|
||||
name="additional_data",
|
||||
field=models.JSONField(
|
||||
blank=True, null=True, verbose_name="additional data"
|
||||
),
|
||||
),
|
||||
]
|
||||
19
auditlog/migrations/0010_alter_logentry_timestamp.py
Normal file
19
auditlog/migrations/0010_alter_logentry_timestamp.py
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
# Generated by Django 4.0.3 on 2022-03-11 23:16
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("auditlog", "0009_alter_logentry_additional_data"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="logentry",
|
||||
name="timestamp",
|
||||
field=models.DateTimeField(
|
||||
auto_now_add=True, db_index=True, verbose_name="timestamp"
|
||||
),
|
||||
),
|
||||
]
|
||||
17
auditlog/migrations/0011_logentry_serialized_data.py
Normal file
17
auditlog/migrations/0011_logentry_serialized_data.py
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
# Generated by Django 4.0 on 2022-08-05 19:55
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("auditlog", "0010_alter_logentry_timestamp"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="logentry",
|
||||
name="serialized_data",
|
||||
field=models.JSONField(null=True),
|
||||
),
|
||||
]
|
||||
21
auditlog/migrations/0012_add_logentry_action_access.py
Normal file
21
auditlog/migrations/0012_add_logentry_action_access.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
# Generated by Django 4.1.1 on 2022-10-13 07:56
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("auditlog", "0011_logentry_serialized_data"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="logentry",
|
||||
name="action",
|
||||
field=models.PositiveSmallIntegerField(
|
||||
choices=[(0, "create"), (1, "update"), (2, "delete"), (3, "access")],
|
||||
db_index=True,
|
||||
verbose_name="action",
|
||||
),
|
||||
),
|
||||
]
|
||||
22
auditlog/migrations/0013_alter_logentry_timestamp.py
Normal file
22
auditlog/migrations/0013_alter_logentry_timestamp.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# Generated by Django 4.1.4 on 2022-12-15 21:24
|
||||
|
||||
import django.utils.timezone
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("auditlog", "0012_add_logentry_action_access"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="logentry",
|
||||
name="timestamp",
|
||||
field=models.DateTimeField(
|
||||
db_index=True,
|
||||
default=django.utils.timezone.now,
|
||||
verbose_name="timestamp",
|
||||
),
|
||||
),
|
||||
]
|
||||
23
auditlog/migrations/0014_logentry_cid.py
Normal file
23
auditlog/migrations/0014_logentry_cid.py
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# Generated by Django 4.1.4 on 2022-12-18 13:24
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("auditlog", "0013_alter_logentry_timestamp"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="logentry",
|
||||
name="cid",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
db_index=True,
|
||||
max_length=255,
|
||||
null=True,
|
||||
verbose_name="Correlation ID",
|
||||
),
|
||||
),
|
||||
]
|
||||
40
auditlog/migrations/0015_alter_logentry_changes.py
Normal file
40
auditlog/migrations/0015_alter_logentry_changes.py
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
# Generated by Django 4.0 on 2022-08-04 15:41
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def two_step_migrations() -> list:
|
||||
if settings.AUDITLOG_TWO_STEP_MIGRATION:
|
||||
return [
|
||||
migrations.RenameField(
|
||||
model_name="logentry",
|
||||
old_name="changes",
|
||||
new_name="changes_text",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="logentry",
|
||||
name="changes",
|
||||
field=models.JSONField(null=True, verbose_name="change message"),
|
||||
),
|
||||
]
|
||||
|
||||
return [
|
||||
migrations.AddField(
|
||||
model_name="logentry",
|
||||
name="changes_text",
|
||||
field=models.TextField(blank=True, verbose_name="change message"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="logentry",
|
||||
name="changes",
|
||||
field=models.JSONField(null=True, verbose_name="change message"),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("auditlog", "0014_logentry_cid"),
|
||||
]
|
||||
|
||||
operations = [*two_step_migrations()]
|
||||
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,
|
||||
),
|
||||
),
|
||||
]
|
||||
179
auditlog/mixins.py
Normal file
179
auditlog/mixins.py
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
from urllib.parse import unquote
|
||||
|
||||
from django import urls as urlresolvers
|
||||
from django.conf import settings
|
||||
from django.contrib import admin
|
||||
from django.contrib.admin.views.main import PAGE_VAR
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.http import HttpRequest
|
||||
from django.template.response import TemplateResponse
|
||||
from django.urls import path, reverse
|
||||
from django.urls.exceptions import NoReverseMatch
|
||||
from django.utils.html import format_html
|
||||
from django.utils.text import capfirst
|
||||
from django.utils.timezone import is_aware, localtime
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from auditlog import get_logentry_model
|
||||
from auditlog.render import get_field_verbose_name, render_logentry_changes_html
|
||||
from auditlog.signals import accessed
|
||||
|
||||
LogEntry = get_logentry_model()
|
||||
|
||||
MAX = 75
|
||||
|
||||
|
||||
class LogEntryAdminMixin:
|
||||
request: HttpRequest
|
||||
CID_TITLE = _("Click to filter by records with this correlation id")
|
||||
|
||||
@admin.display(description=_("Created"))
|
||||
def created(self, obj):
|
||||
if is_aware(obj.timestamp):
|
||||
return localtime(obj.timestamp)
|
||||
return obj.timestamp
|
||||
|
||||
@admin.display(description=_("User"))
|
||||
def user_url(self, obj):
|
||||
if obj.actor:
|
||||
app_label, model = settings.AUTH_USER_MODEL.split(".")
|
||||
viewname = f"admin:{app_label}_{model.lower()}_change"
|
||||
try:
|
||||
link = urlresolvers.reverse(viewname, args=[obj.actor.pk])
|
||||
except NoReverseMatch:
|
||||
return "%s" % (obj.actor)
|
||||
return format_html('<a href="{}">{}</a>', link, obj.actor)
|
||||
|
||||
return "system"
|
||||
|
||||
@admin.display(description=_("Resource"))
|
||||
def resource_url(self, obj):
|
||||
app_label, model = obj.content_type.app_label, obj.content_type.model
|
||||
viewname = f"admin:{app_label}_{model}_change"
|
||||
try:
|
||||
args = [obj.object_pk] if obj.object_id is None else [obj.object_id]
|
||||
link = urlresolvers.reverse(viewname, args=args)
|
||||
except NoReverseMatch:
|
||||
return obj.object_repr
|
||||
else:
|
||||
return format_html(
|
||||
'<a href="{}">{} - {}</a>', link, obj.content_type, obj.object_repr
|
||||
)
|
||||
|
||||
@admin.display(description=_("Changes"))
|
||||
def msg_short(self, obj):
|
||||
if obj.action in [LogEntry.Action.DELETE, LogEntry.Action.ACCESS]:
|
||||
return "" # delete
|
||||
changes = obj.changes_dict
|
||||
s = "" if len(changes) == 1 else "s"
|
||||
fields = ", ".join(changes.keys())
|
||||
if len(fields) > MAX:
|
||||
i = fields.rfind(" ", 0, MAX)
|
||||
fields = fields[:i] + " .."
|
||||
return "%d change%s: %s" % (len(changes), s, fields)
|
||||
|
||||
@admin.display(description=_("Changes"))
|
||||
def msg(self, obj):
|
||||
return render_logentry_changes_html(obj)
|
||||
|
||||
@admin.display(description="Correlation ID")
|
||||
def cid_url(self, obj):
|
||||
cid = obj.cid
|
||||
if cid:
|
||||
url = self._add_query_parameter("cid", cid)
|
||||
return format_html(
|
||||
'<a href="{}" title="{}">{}</a>', url, self.CID_TITLE, cid
|
||||
)
|
||||
|
||||
def _add_query_parameter(self, key: str, value: str):
|
||||
full_path = self.request.get_full_path()
|
||||
delimiter = "&" if "?" in full_path else "?"
|
||||
|
||||
return f"{full_path}{delimiter}{key}={value}"
|
||||
|
||||
def field_verbose_name(self, obj, field_name: str):
|
||||
"""
|
||||
Use `auditlog.render.get_field_verbose_name` instead.
|
||||
This method is kept for backward compatibility.
|
||||
"""
|
||||
return get_field_verbose_name(obj, field_name)
|
||||
|
||||
|
||||
class LogAccessMixin:
|
||||
def render_to_response(self, context, **response_kwargs):
|
||||
obj = self.get_object()
|
||||
accessed.send(obj.__class__, instance=obj)
|
||||
return super().render_to_response(context, **response_kwargs)
|
||||
|
||||
|
||||
class AuditlogHistoryAdminMixin:
|
||||
"""
|
||||
Add an audit log history view to a model admin.
|
||||
"""
|
||||
|
||||
auditlog_history_template = "auditlog/object_history.html"
|
||||
show_auditlog_history_link = False
|
||||
auditlog_history_per_page = 10
|
||||
|
||||
def get_list_display(self, request):
|
||||
list_display = list(super().get_list_display(request))
|
||||
if self.show_auditlog_history_link and "auditlog_link" not in list_display:
|
||||
list_display.append("auditlog_link")
|
||||
|
||||
return list_display
|
||||
|
||||
def get_urls(self):
|
||||
opts = self.model._meta
|
||||
info = opts.app_label, opts.model_name
|
||||
my_urls = [
|
||||
path(
|
||||
"<path:object_id>/auditlog/",
|
||||
self.admin_site.admin_view(self.auditlog_history_view),
|
||||
name="%s_%s_auditlog" % info,
|
||||
)
|
||||
]
|
||||
|
||||
return my_urls + super().get_urls()
|
||||
|
||||
def auditlog_history_view(self, request, object_id, extra_context=None):
|
||||
obj = self.get_object(request, unquote(object_id))
|
||||
if not self.has_view_permission(request, obj):
|
||||
raise PermissionDenied
|
||||
|
||||
log_entries = (
|
||||
LogEntry.objects.get_for_object(obj)
|
||||
.select_related("actor")
|
||||
.order_by("-timestamp")
|
||||
)
|
||||
|
||||
paginator = self.get_paginator(
|
||||
request, log_entries, self.auditlog_history_per_page
|
||||
)
|
||||
page_number = request.GET.get(PAGE_VAR, 1)
|
||||
page_obj = paginator.get_page(page_number)
|
||||
page_range = paginator.get_elided_page_range(page_obj.number)
|
||||
|
||||
context = {
|
||||
**self.admin_site.each_context(request),
|
||||
"title": _("Audit log: %s") % obj,
|
||||
"module_name": str(capfirst(self.model._meta.verbose_name_plural)),
|
||||
"page_range": page_range,
|
||||
"page_var": PAGE_VAR,
|
||||
"pagination_required": paginator.count > self.auditlog_history_per_page,
|
||||
"object": obj,
|
||||
"opts": self.model._meta,
|
||||
"log_entries": page_obj,
|
||||
**(extra_context or {}),
|
||||
}
|
||||
|
||||
return TemplateResponse(request, self.auditlog_history_template, context)
|
||||
|
||||
@admin.display(description=_("Audit log"))
|
||||
def auditlog_link(self, obj):
|
||||
opts = self.model._meta
|
||||
url = reverse(
|
||||
f"admin:{opts.app_label}_{opts.model_name}_auditlog",
|
||||
args=[obj.pk],
|
||||
)
|
||||
|
||||
return format_html('<a href="{}">{}</a>', url, _("View"))
|
||||
655
auditlog/models.py
Normal file
655
auditlog/models.py
Normal file
|
|
@ -0,0 +1,655 @@
|
|||
import ast
|
||||
import contextlib
|
||||
import json
|
||||
from collections.abc import Callable
|
||||
from copy import deepcopy
|
||||
from datetime import timezone
|
||||
from typing import Any
|
||||
|
||||
from dateutil import parser
|
||||
from dateutil.tz import gettz
|
||||
from django.conf import settings
|
||||
from django.contrib.contenttypes.fields import GenericRelation
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core import serializers
|
||||
from django.core.exceptions import (
|
||||
FieldDoesNotExist,
|
||||
ObjectDoesNotExist,
|
||||
ValidationError,
|
||||
)
|
||||
from django.db import DEFAULT_DB_ALIAS, models
|
||||
from django.db.models import Q, QuerySet
|
||||
from django.utils import formats
|
||||
from django.utils import timezone as django_timezone
|
||||
from django.utils.encoding import smart_str
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from auditlog import get_logentry_model
|
||||
from auditlog.diff import get_mask_function
|
||||
|
||||
DEFAULT_OBJECT_REPR = "<error forming object repr>"
|
||||
|
||||
|
||||
class LogEntryManager(models.Manager):
|
||||
"""
|
||||
Custom manager for the :py:class:`LogEntry` model.
|
||||
"""
|
||||
|
||||
def log_create(self, instance, force_log: bool = False, **kwargs):
|
||||
"""
|
||||
Helper method to create a new log entry. This method automatically populates some fields when no
|
||||
explicit value is given.
|
||||
|
||||
:param instance: The model instance to log a change for.
|
||||
:type instance: Model
|
||||
:param force_log: Create a LogEntry even if no changes exist.
|
||||
:type force_log: bool
|
||||
:param kwargs: Field overrides for the :py:class:`LogEntry` object.
|
||||
:return: The new log entry or `None` if there were no changes.
|
||||
:rtype: LogEntry
|
||||
"""
|
||||
from auditlog.cid import get_cid
|
||||
|
||||
changes = kwargs.get("changes", None)
|
||||
pk = self._get_pk_value(instance)
|
||||
|
||||
if changes is not None or force_log:
|
||||
kwargs.setdefault(
|
||||
"content_type", ContentType.objects.get_for_model(instance)
|
||||
)
|
||||
kwargs.setdefault("object_pk", pk)
|
||||
try:
|
||||
object_repr = smart_str(instance)
|
||||
except ObjectDoesNotExist:
|
||||
object_repr = DEFAULT_OBJECT_REPR
|
||||
kwargs.setdefault("object_repr", object_repr)
|
||||
kwargs.setdefault(
|
||||
"serialized_data", self._get_serialized_data_or_none(instance)
|
||||
)
|
||||
|
||||
if isinstance(pk, int):
|
||||
kwargs.setdefault("object_id", pk)
|
||||
|
||||
get_additional_data = getattr(instance, "get_additional_data", None)
|
||||
if callable(get_additional_data):
|
||||
kwargs.setdefault("additional_data", get_additional_data())
|
||||
|
||||
# set correlation id
|
||||
kwargs.setdefault("cid", get_cid())
|
||||
return self.create(**kwargs)
|
||||
return None
|
||||
|
||||
def log_m2m_changes(
|
||||
self, changed_queryset, instance, operation, field_name, **kwargs
|
||||
):
|
||||
"""Create a new "changed" log entry from m2m record.
|
||||
|
||||
:param changed_queryset: The added or removed related objects.
|
||||
:type changed_queryset: QuerySet
|
||||
:param instance: The model instance to log a change for.
|
||||
:type instance: Model
|
||||
:param operation: "add" or "delete".
|
||||
:type action: str
|
||||
:param field_name: The name of the changed m2m field.
|
||||
:type field_name: str
|
||||
:param kwargs: Field overrides for the :py:class:`LogEntry` object.
|
||||
:return: The new log entry or `None` if there were no changes.
|
||||
:rtype: LogEntry
|
||||
"""
|
||||
from auditlog.cid import get_cid
|
||||
|
||||
pk = self._get_pk_value(instance)
|
||||
if changed_queryset:
|
||||
kwargs.setdefault(
|
||||
"content_type", ContentType.objects.get_for_model(instance)
|
||||
)
|
||||
kwargs.setdefault("object_pk", pk)
|
||||
try:
|
||||
object_repr = smart_str(instance)
|
||||
except ObjectDoesNotExist:
|
||||
object_repr = DEFAULT_OBJECT_REPR
|
||||
kwargs.setdefault("object_repr", object_repr)
|
||||
kwargs.setdefault("action", LogEntry.Action.UPDATE)
|
||||
|
||||
if isinstance(pk, int):
|
||||
kwargs.setdefault("object_id", pk)
|
||||
|
||||
get_additional_data = getattr(instance, "get_additional_data", None)
|
||||
if callable(get_additional_data):
|
||||
kwargs.setdefault("additional_data", get_additional_data())
|
||||
|
||||
objects = [smart_str(instance) for instance in changed_queryset]
|
||||
kwargs["changes"] = {
|
||||
field_name: {
|
||||
"type": "m2m",
|
||||
"operation": operation,
|
||||
"objects": objects,
|
||||
}
|
||||
}
|
||||
|
||||
kwargs.setdefault("cid", get_cid())
|
||||
return self.create(**kwargs)
|
||||
|
||||
return None
|
||||
|
||||
def get_for_object(self, instance):
|
||||
"""
|
||||
Get log entries for the specified model instance.
|
||||
|
||||
:param instance: The model instance to get log entries for.
|
||||
:type instance: Model
|
||||
:return: QuerySet of log entries for the given model instance.
|
||||
:rtype: QuerySet
|
||||
"""
|
||||
# Return empty queryset if the given model instance is not a model instance.
|
||||
if not isinstance(instance, models.Model):
|
||||
return self.none()
|
||||
|
||||
content_type = ContentType.objects.get_for_model(instance.__class__)
|
||||
pk = self._get_pk_value(instance)
|
||||
|
||||
if isinstance(pk, int):
|
||||
return self.filter(content_type=content_type, object_id=pk)
|
||||
else:
|
||||
return self.filter(content_type=content_type, object_pk=smart_str(pk))
|
||||
|
||||
def get_for_objects(self, queryset):
|
||||
"""
|
||||
Get log entries for the objects in the specified queryset.
|
||||
|
||||
:param queryset: The queryset to get the log entries for.
|
||||
:type queryset: QuerySet
|
||||
:return: The LogEntry objects for the objects in the given queryset.
|
||||
:rtype: QuerySet
|
||||
"""
|
||||
if not isinstance(queryset, QuerySet) or queryset.count() == 0:
|
||||
return self.none()
|
||||
|
||||
content_type = ContentType.objects.get_for_model(queryset.model)
|
||||
primary_keys = list(
|
||||
queryset.values_list(queryset.model._meta.pk.name, flat=True)
|
||||
)
|
||||
|
||||
if isinstance(primary_keys[0], int):
|
||||
return (
|
||||
self.filter(content_type=content_type)
|
||||
.filter(Q(object_id__in=primary_keys))
|
||||
.distinct()
|
||||
)
|
||||
elif isinstance(queryset.model._meta.pk, models.UUIDField):
|
||||
primary_keys = [smart_str(pk) for pk in primary_keys]
|
||||
return (
|
||||
self.filter(content_type=content_type)
|
||||
.filter(Q(object_pk__in=primary_keys))
|
||||
.distinct()
|
||||
)
|
||||
else:
|
||||
return (
|
||||
self.filter(content_type=content_type)
|
||||
.filter(Q(object_pk__in=primary_keys))
|
||||
.distinct()
|
||||
)
|
||||
|
||||
def get_for_model(self, model):
|
||||
"""
|
||||
Get log entries for all objects of a specified type.
|
||||
|
||||
:param model: The model to get log entries for.
|
||||
:type model: class
|
||||
:return: QuerySet of log entries for the given model.
|
||||
:rtype: QuerySet
|
||||
"""
|
||||
# Return empty queryset if the given object is not valid.
|
||||
if not issubclass(model, models.Model):
|
||||
return self.none()
|
||||
|
||||
content_type = ContentType.objects.get_for_model(model)
|
||||
|
||||
return self.filter(content_type=content_type)
|
||||
|
||||
def _get_pk_value(self, instance):
|
||||
"""
|
||||
Get the primary key field value for a model instance.
|
||||
|
||||
:param instance: The model instance to get the primary key for.
|
||||
:type instance: Model
|
||||
:return: The primary key value of the given model instance.
|
||||
"""
|
||||
# Should be equivalent to `instance.pk`.
|
||||
pk_field = instance._meta.pk.attname
|
||||
pk = getattr(instance, pk_field, None)
|
||||
|
||||
# Check to make sure that we got a pk not a model object.
|
||||
# Should be guaranteed as we used `attname` above, not `name`.
|
||||
assert not isinstance(pk, models.Model)
|
||||
return pk
|
||||
|
||||
def _get_serialized_data_or_none(self, instance):
|
||||
from auditlog.registry import auditlog
|
||||
|
||||
if not auditlog.contains(instance.__class__):
|
||||
return None
|
||||
|
||||
opts = auditlog.get_serialize_options(instance.__class__)
|
||||
if not opts["serialize_data"]:
|
||||
return None
|
||||
|
||||
model_fields = auditlog.get_model_fields(instance.__class__)
|
||||
kwargs = opts.get("serialize_kwargs", {})
|
||||
|
||||
if opts["serialize_auditlog_fields_only"]:
|
||||
kwargs.setdefault(
|
||||
"fields", self._get_applicable_model_fields(instance, model_fields)
|
||||
)
|
||||
|
||||
instance_copy = self._get_copy_with_python_typed_fields(instance)
|
||||
data = dict(
|
||||
json.loads(serializers.serialize("json", (instance_copy,), **kwargs))[0]
|
||||
)
|
||||
|
||||
mask_fields = model_fields["mask_fields"]
|
||||
if mask_fields:
|
||||
data = self._mask_serialized_fields(data, mask_fields, model_fields)
|
||||
|
||||
return data
|
||||
|
||||
def _get_copy_with_python_typed_fields(self, instance):
|
||||
"""
|
||||
Attempt to create copy of instance and coerce types on instance fields
|
||||
|
||||
The Django core serializer assumes that the values on object fields are
|
||||
correctly typed to their respective fields. Updates made to an object's
|
||||
in-memory state may not meet this assumption. To prevent this violation, values
|
||||
are typed by calling `to_python` from the field object, the result is set on a
|
||||
copy of the instance and the copy is sent to the serializer.
|
||||
"""
|
||||
try:
|
||||
instance_copy = deepcopy(instance)
|
||||
except TypeError:
|
||||
instance_copy = instance
|
||||
for field in instance_copy._meta.fields:
|
||||
if not field.is_relation:
|
||||
value = getattr(instance_copy, field.name)
|
||||
try:
|
||||
setattr(instance_copy, field.name, field.to_python(value))
|
||||
except ValidationError:
|
||||
continue
|
||||
return instance_copy
|
||||
|
||||
def _get_applicable_model_fields(
|
||||
self, instance, model_fields: dict[str, list[str]]
|
||||
) -> list[str]:
|
||||
include_fields = model_fields["include_fields"]
|
||||
exclude_fields = model_fields["exclude_fields"]
|
||||
all_field_names = [field.name for field in instance._meta.fields]
|
||||
|
||||
if not include_fields and not exclude_fields:
|
||||
return all_field_names
|
||||
|
||||
return list(set(include_fields or all_field_names).difference(exclude_fields))
|
||||
|
||||
def _mask_serialized_fields(
|
||||
self, data: dict[str, Any], mask_fields: list[str], model_fields: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
all_field_data = data.pop("fields")
|
||||
mask_func = get_mask_function(model_fields.get("mask_callable"))
|
||||
|
||||
masked_field_data = {}
|
||||
for key, value in all_field_data.items():
|
||||
if isinstance(value, str) and key in mask_fields:
|
||||
masked_field_data[key] = mask_func(value)
|
||||
else:
|
||||
masked_field_data[key] = value
|
||||
|
||||
data["fields"] = masked_field_data
|
||||
return data
|
||||
|
||||
|
||||
class AbstractLogEntry(models.Model):
|
||||
"""
|
||||
Represents an entry in the audit log. The content type is saved along with the textual and numeric
|
||||
(if available) primary key, as well as the textual representation of the object when it was saved.
|
||||
It holds the action performed and the fields that were changed in the transaction.
|
||||
|
||||
If AuditlogMiddleware is used, the actor will be set automatically. Keep in mind that
|
||||
editing / re-saving LogEntry instances may set the actor to a wrong value - editing LogEntry
|
||||
instances is not recommended (and it should not be necessary).
|
||||
"""
|
||||
|
||||
class Action:
|
||||
"""
|
||||
The actions that Auditlog distinguishes: creating, updating and deleting objects. Viewing objects
|
||||
is not logged. The values of the actions are numeric, a higher integer value means a more intrusive
|
||||
action. This may be useful in some cases when comparing actions because the ``__lt``, ``__lte``,
|
||||
``__gt``, ``__gte`` lookup filters can be used in queries.
|
||||
|
||||
The valid actions are :py:attr:`Action.CREATE`, :py:attr:`Action.UPDATE`,
|
||||
:py:attr:`Action.DELETE` and :py:attr:`Action.ACCESS`.
|
||||
"""
|
||||
|
||||
CREATE = 0
|
||||
UPDATE = 1
|
||||
DELETE = 2
|
||||
ACCESS = 3
|
||||
|
||||
choices = (
|
||||
(CREATE, _("create")),
|
||||
(UPDATE, _("update")),
|
||||
(DELETE, _("delete")),
|
||||
(ACCESS, _("access")),
|
||||
)
|
||||
|
||||
content_type = models.ForeignKey(
|
||||
to="contenttypes.ContentType",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="+",
|
||||
verbose_name=_("content type"),
|
||||
)
|
||||
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=Action.choices, verbose_name=_("action"), db_index=True
|
||||
)
|
||||
changes_text = models.TextField(blank=True, verbose_name=_("change message"))
|
||||
changes = models.JSONField(null=True, verbose_name=_("change message"))
|
||||
actor = models.ForeignKey(
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
blank=True,
|
||||
null=True,
|
||||
related_name="+",
|
||||
verbose_name=_("actor"),
|
||||
)
|
||||
cid = models.CharField(
|
||||
max_length=255,
|
||||
db_index=True,
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_("Correlation ID"),
|
||||
)
|
||||
remote_addr = models.GenericIPAddressField(
|
||||
blank=True, null=True, verbose_name=_("remote address")
|
||||
)
|
||||
remote_port = models.PositiveIntegerField(
|
||||
blank=True, null=True, verbose_name=_("remote port")
|
||||
)
|
||||
timestamp = models.DateTimeField(
|
||||
default=django_timezone.now,
|
||||
db_index=True,
|
||||
verbose_name=_("timestamp"),
|
||||
)
|
||||
additional_data = models.JSONField(
|
||||
blank=True, null=True, verbose_name=_("additional data")
|
||||
)
|
||||
actor_email = models.CharField(
|
||||
blank=True, null=True, max_length=254, verbose_name=_("actor email")
|
||||
)
|
||||
|
||||
objects = LogEntryManager()
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
get_latest_by = "timestamp"
|
||||
ordering = ["-timestamp"]
|
||||
verbose_name = _("log entry")
|
||||
verbose_name_plural = _("log entries")
|
||||
|
||||
def __str__(self):
|
||||
if self.action == self.Action.CREATE:
|
||||
fstring = _("Created {repr:s}")
|
||||
elif self.action == self.Action.UPDATE:
|
||||
fstring = _("Updated {repr:s}")
|
||||
elif self.action == self.Action.DELETE:
|
||||
fstring = _("Deleted {repr:s}")
|
||||
else:
|
||||
fstring = _("Logged {repr:s}")
|
||||
|
||||
return fstring.format(repr=self.object_repr)
|
||||
|
||||
@property
|
||||
def changes_dict(self):
|
||||
"""
|
||||
:return: The changes recorded in this log entry as a dictionary object.
|
||||
"""
|
||||
return changes_func(self)
|
||||
|
||||
@property
|
||||
def changes_str(self, colon=": ", arrow=" \u2192 ", separator="; "):
|
||||
"""
|
||||
Return the changes recorded in this log entry as a string. The formatting of the string can be
|
||||
customized by setting alternate values for colon, arrow and separator. If the formatting is still
|
||||
not satisfying, please use :py:func:`LogEntry.changes_dict` and format the string yourself.
|
||||
|
||||
:param colon: The string to place between the field name and the values.
|
||||
:param arrow: The string to place between each old and new value (non-m2m field changes only).
|
||||
:param separator: The string to place between each field.
|
||||
:return: A readable string of the changes in this log entry.
|
||||
"""
|
||||
substrings = []
|
||||
|
||||
for field, value in sorted(self.changes_dict.items()):
|
||||
if isinstance(value, (list, tuple)) and len(value) == 2:
|
||||
# handle regular field change
|
||||
substring = "{field_name:s}{colon:s}{old:s}{arrow:s}{new:s}".format(
|
||||
field_name=field,
|
||||
colon=colon,
|
||||
old=value[0],
|
||||
arrow=arrow,
|
||||
new=value[1],
|
||||
)
|
||||
substrings.append(substring)
|
||||
elif isinstance(value, dict) and value.get("type") == "m2m":
|
||||
# handle m2m change
|
||||
substring = (
|
||||
f"{field}{colon}{value['operation']} {sorted(value['objects'])}"
|
||||
)
|
||||
substrings.append(substring)
|
||||
|
||||
return separator.join(substrings)
|
||||
|
||||
@property
|
||||
def changes_display_dict(self):
|
||||
"""
|
||||
:return: The changes recorded in this log entry intended for display to users as a dictionary object.
|
||||
"""
|
||||
from auditlog.registry import auditlog
|
||||
|
||||
# Get the model and model_fields, but gracefully handle the case where the model no longer exists
|
||||
model = self.content_type.model_class()
|
||||
model_fields = None
|
||||
if auditlog.contains(model._meta.model):
|
||||
model_fields = auditlog.get_model_fields(model._meta.model)
|
||||
|
||||
if settings.AUDITLOG_STORE_JSON_CHANGES:
|
||||
changes_dict = {}
|
||||
for field_name, values in self.changes_dict.items():
|
||||
values_as_strings = [str(v) for v in values]
|
||||
changes_dict[field_name] = values_as_strings
|
||||
else:
|
||||
changes_dict = self.changes_dict
|
||||
|
||||
changes_display_dict = {}
|
||||
# grab the changes_dict and iterate through
|
||||
for field_name, values in changes_dict.items():
|
||||
# try to get the field attribute on the model
|
||||
try:
|
||||
field = model._meta.get_field(field_name)
|
||||
except FieldDoesNotExist:
|
||||
changes_display_dict[field_name] = values
|
||||
continue
|
||||
values_display = []
|
||||
# handle choices fields and Postgres ArrayField to get human-readable version
|
||||
choices_dict = None
|
||||
if getattr(field, "choices", []):
|
||||
choices_dict = dict(field.choices)
|
||||
if getattr(getattr(field, "base_field", None), "choices", []):
|
||||
choices_dict = dict(field.base_field.choices)
|
||||
|
||||
if choices_dict:
|
||||
for value in values:
|
||||
try:
|
||||
value = ast.literal_eval(value)
|
||||
if type(value) is [].__class__:
|
||||
values_display.append(
|
||||
", ".join(
|
||||
[choices_dict.get(val, "None") for val in value]
|
||||
)
|
||||
)
|
||||
else:
|
||||
values_display.append(choices_dict.get(value, "None"))
|
||||
except Exception:
|
||||
values_display.append(choices_dict.get(value, "None"))
|
||||
else:
|
||||
try:
|
||||
field_type = field.get_internal_type()
|
||||
except AttributeError:
|
||||
# if the field is a relationship it has no internal type and exclude it
|
||||
continue
|
||||
for value in values:
|
||||
# handle case where field is a datetime, date, or time type
|
||||
if field_type in ["DateTimeField", "DateField", "TimeField"]:
|
||||
try:
|
||||
value = parser.parse(value)
|
||||
if field_type == "DateField":
|
||||
value = value.date()
|
||||
elif field_type == "TimeField":
|
||||
value = value.time()
|
||||
elif field_type == "DateTimeField":
|
||||
value = value.replace(tzinfo=timezone.utc)
|
||||
value = value.astimezone(gettz(settings.TIME_ZONE))
|
||||
value = formats.localize(value)
|
||||
except ValueError:
|
||||
pass
|
||||
elif field_type in ["ForeignKey", "OneToOneField"]:
|
||||
value = self._get_changes_display_for_fk_field(field, value)
|
||||
|
||||
truncate_at = settings.AUDITLOG_CHANGE_DISPLAY_TRUNCATE_LENGTH
|
||||
if 0 <= truncate_at < len(value):
|
||||
value = value[:truncate_at] + ("..." if truncate_at > 0 else "")
|
||||
|
||||
values_display.append(value)
|
||||
|
||||
# Use verbose_name from mapping if available, otherwise determine from field
|
||||
if model_fields and field.name in model_fields["mapping_fields"]:
|
||||
verbose_name = model_fields["mapping_fields"][field.name]
|
||||
else:
|
||||
verbose_name = getattr(field, "verbose_name", field.name)
|
||||
|
||||
changes_display_dict[verbose_name] = values_display
|
||||
return changes_display_dict
|
||||
|
||||
def _get_changes_display_for_fk_field(
|
||||
self, field: models.ForeignKey | models.OneToOneField, value: Any
|
||||
) -> str:
|
||||
"""
|
||||
:return: A string representing a given FK value and the field to which it belongs
|
||||
"""
|
||||
# Return "None" if the FK value is "None".
|
||||
if value == "None":
|
||||
return value
|
||||
|
||||
# Attempt to convert given value to the PK type for the related model
|
||||
try:
|
||||
pk_value = field.related_model._meta.pk.to_python(value)
|
||||
# ValidationError will handle legacy values where string representations were
|
||||
# stored rather than PKs. This will also handle cases where the PK type is
|
||||
# changed between the time the LogEntry is created and this method is called.
|
||||
except ValidationError:
|
||||
return value
|
||||
# Attempt to return the string representation of the object
|
||||
try:
|
||||
related_model_manager = _get_manager_from_settings(field.related_model)
|
||||
|
||||
return smart_str(related_model_manager.get(pk=pk_value))
|
||||
# ObjectDoesNotExist will be raised if the object was deleted.
|
||||
except ObjectDoesNotExist:
|
||||
return f"Deleted '{field.related_model.__name__}' ({value})"
|
||||
|
||||
|
||||
class LogEntry(AbstractLogEntry):
|
||||
class Meta(AbstractLogEntry.Meta):
|
||||
swappable = "AUDITLOG_LOGENTRY_MODEL"
|
||||
|
||||
|
||||
class AuditlogHistoryField(GenericRelation):
|
||||
"""
|
||||
A subclass of py:class:`django.contrib.contenttypes.fields.GenericRelation` that sets some default
|
||||
variables. This makes it easier to access Auditlog's log entries, for example in templates.
|
||||
|
||||
By default, this field will assume that your primary keys are numeric, simply because this is the most
|
||||
common case. However, if you have a non-integer primary key, you can simply pass ``pk_indexable=False``
|
||||
to the constructor, and Auditlog will fall back to using a non-indexed text based field for this model.
|
||||
|
||||
Using this field will not automatically register the model for automatic logging. This is done so you
|
||||
can be more flexible with how you use this field.
|
||||
|
||||
:param pk_indexable: Whether the primary key for this model is not an :py:class:`int` or :py:class:`long`.
|
||||
:type pk_indexable: bool
|
||||
:param delete_related: Delete referenced auditlog entries together with the tracked object.
|
||||
Defaults to False to keep the integrity of the auditlog.
|
||||
:type delete_related: bool
|
||||
"""
|
||||
|
||||
def __init__(self, pk_indexable=True, delete_related=False, **kwargs):
|
||||
kwargs["to"] = get_logentry_model()
|
||||
|
||||
if pk_indexable:
|
||||
kwargs["object_id_field"] = "object_id"
|
||||
else:
|
||||
kwargs["object_id_field"] = "object_pk"
|
||||
|
||||
kwargs["content_type_field"] = "content_type"
|
||||
self.delete_related = delete_related
|
||||
super().__init__(**kwargs)
|
||||
|
||||
def bulk_related_objects(self, objs, using=DEFAULT_DB_ALIAS):
|
||||
"""
|
||||
Return all objects related to ``objs`` via this ``GenericRelation``.
|
||||
"""
|
||||
if self.delete_related:
|
||||
return super().bulk_related_objects(objs, using)
|
||||
|
||||
# When deleting, Collector.collect() finds related objects using this
|
||||
# method. However, because we don't want to delete these related
|
||||
# objects, we simply return an empty list.
|
||||
return []
|
||||
|
||||
|
||||
# should I add a signal receiver for setting_changed?
|
||||
changes_func = None
|
||||
|
||||
|
||||
def _changes_func() -> Callable[[LogEntry], dict]:
|
||||
def json_then_text(instance: LogEntry) -> dict:
|
||||
if instance.changes:
|
||||
return instance.changes
|
||||
elif instance.changes_text:
|
||||
with contextlib.suppress(ValueError):
|
||||
return json.loads(instance.changes_text)
|
||||
return {}
|
||||
|
||||
def default(instance: LogEntry) -> dict:
|
||||
return instance.changes or {}
|
||||
|
||||
if settings.AUDITLOG_USE_TEXT_CHANGES_IF_JSON_IS_NOT_PRESENT:
|
||||
return json_then_text
|
||||
return default
|
||||
|
||||
|
||||
def _get_manager_from_settings(model: type[models.Model]) -> models.Manager:
|
||||
"""
|
||||
Get model manager as selected by AUDITLOG_USE_BASE_MANAGER.
|
||||
|
||||
- True: return model._meta.base_manager
|
||||
- False: return model._meta.default_manager
|
||||
"""
|
||||
if settings.AUDITLOG_USE_BASE_MANAGER:
|
||||
return model._meta.base_manager
|
||||
else:
|
||||
return model._meta.default_manager
|
||||
198
auditlog/receivers.py
Normal file
198
auditlog/receivers.py
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
from functools import wraps
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from auditlog import get_logentry_model
|
||||
from auditlog.context import auditlog_disabled
|
||||
from auditlog.diff import model_instance_diff
|
||||
from auditlog.models import _get_manager_from_settings
|
||||
from auditlog.signals import post_log, pre_log
|
||||
|
||||
|
||||
def check_disable(signal_handler):
|
||||
"""
|
||||
Decorator that passes along disabled in kwargs if any of the following is true:
|
||||
- 'auditlog_disabled' from threadlocal is true
|
||||
- raw = True and AUDITLOG_DISABLE_ON_RAW_SAVE is True
|
||||
"""
|
||||
|
||||
@wraps(signal_handler)
|
||||
def wrapper(*args, **kwargs):
|
||||
try:
|
||||
auditlog_disabled_value = auditlog_disabled.get()
|
||||
except LookupError:
|
||||
auditlog_disabled_value = False
|
||||
if not auditlog_disabled_value and not (
|
||||
kwargs.get("raw") and settings.AUDITLOG_DISABLE_ON_RAW_SAVE
|
||||
):
|
||||
signal_handler(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
@check_disable
|
||||
def log_create(sender, instance, created, **kwargs):
|
||||
"""
|
||||
Signal receiver that creates a log entry when a model instance is first saved to the database.
|
||||
|
||||
Direct use is discouraged, connect your model through :py:func:`auditlog.registry.register` instead.
|
||||
"""
|
||||
if created:
|
||||
_create_log_entry(
|
||||
action=get_logentry_model().Action.CREATE,
|
||||
instance=instance,
|
||||
sender=sender,
|
||||
diff_old=None,
|
||||
diff_new=instance,
|
||||
use_json_for_changes=settings.AUDITLOG_STORE_JSON_CHANGES,
|
||||
)
|
||||
|
||||
|
||||
@check_disable
|
||||
def log_update(sender, instance, **kwargs):
|
||||
"""
|
||||
Signal receiver that creates a log entry when a model instance is changed and saved to the database.
|
||||
|
||||
Direct use is discouraged, connect your model through :py:func:`auditlog.registry.register` instead.
|
||||
"""
|
||||
if not instance._state.adding and instance.pk is not None:
|
||||
update_fields = kwargs.get("update_fields", None)
|
||||
old = _get_manager_from_settings(sender).filter(pk=instance.pk).first()
|
||||
_create_log_entry(
|
||||
action=get_logentry_model().Action.UPDATE,
|
||||
instance=instance,
|
||||
sender=sender,
|
||||
diff_old=old,
|
||||
diff_new=instance,
|
||||
fields_to_check=update_fields,
|
||||
use_json_for_changes=settings.AUDITLOG_STORE_JSON_CHANGES,
|
||||
)
|
||||
|
||||
|
||||
@check_disable
|
||||
def log_delete(sender, instance, **kwargs):
|
||||
"""
|
||||
Signal receiver that creates a log entry when a model instance is deleted from the database.
|
||||
|
||||
Direct use is discouraged, connect your model through :py:func:`auditlog.registry.register` instead.
|
||||
"""
|
||||
if instance.pk is not None:
|
||||
_create_log_entry(
|
||||
action=get_logentry_model().Action.DELETE,
|
||||
instance=instance,
|
||||
sender=sender,
|
||||
diff_old=instance,
|
||||
diff_new=None,
|
||||
use_json_for_changes=settings.AUDITLOG_STORE_JSON_CHANGES,
|
||||
)
|
||||
|
||||
|
||||
def log_access(sender, instance, **kwargs):
|
||||
"""
|
||||
Signal receiver that creates a log entry when a model instance is accessed in a AccessLogDetailView.
|
||||
|
||||
Direct use is discouraged, connect your model through :py:func:`auditlog.registry.register` instead.
|
||||
"""
|
||||
if instance.pk is not None:
|
||||
_create_log_entry(
|
||||
action=get_logentry_model().Action.ACCESS,
|
||||
instance=instance,
|
||||
sender=sender,
|
||||
diff_old=None,
|
||||
diff_new=None,
|
||||
force_log=True,
|
||||
use_json_for_changes=settings.AUDITLOG_STORE_JSON_CHANGES,
|
||||
)
|
||||
|
||||
|
||||
def _create_log_entry(
|
||||
action,
|
||||
instance,
|
||||
sender,
|
||||
diff_old,
|
||||
diff_new,
|
||||
fields_to_check=None,
|
||||
force_log=False,
|
||||
use_json_for_changes=False,
|
||||
):
|
||||
pre_log_results = pre_log.send(
|
||||
sender,
|
||||
instance=instance,
|
||||
action=action,
|
||||
)
|
||||
|
||||
if any(item[1] is False for item in pre_log_results):
|
||||
return
|
||||
LogEntry = get_logentry_model()
|
||||
|
||||
error = None
|
||||
log_entry = None
|
||||
changes = None
|
||||
try:
|
||||
changes = model_instance_diff(
|
||||
diff_old,
|
||||
diff_new,
|
||||
fields_to_check=fields_to_check,
|
||||
use_json_for_changes=use_json_for_changes,
|
||||
)
|
||||
|
||||
if force_log or changes:
|
||||
log_entry = LogEntry.objects.log_create(
|
||||
instance,
|
||||
action=action,
|
||||
changes=changes,
|
||||
force_log=force_log,
|
||||
)
|
||||
except BaseException as e:
|
||||
error = e
|
||||
finally:
|
||||
if log_entry or error:
|
||||
post_log.send(
|
||||
sender,
|
||||
instance=instance,
|
||||
instance_old=diff_old,
|
||||
action=action,
|
||||
error=error,
|
||||
pre_log_results=pre_log_results,
|
||||
changes=changes,
|
||||
log_entry=log_entry,
|
||||
log_created=log_entry is not None,
|
||||
use_json_for_changes=settings.AUDITLOG_STORE_JSON_CHANGES,
|
||||
)
|
||||
if error:
|
||||
raise error
|
||||
|
||||
|
||||
def make_log_m2m_changes(field_name):
|
||||
"""Return a handler for m2m_changed with field_name enclosed."""
|
||||
|
||||
@check_disable
|
||||
def log_m2m_changes(signal, action, **kwargs):
|
||||
"""Handle m2m_changed and call LogEntry.objects.log_m2m_changes as needed."""
|
||||
if action not in ["post_add", "post_clear", "post_remove"]:
|
||||
return
|
||||
LogEntry = get_logentry_model()
|
||||
|
||||
model_manager = _get_manager_from_settings(kwargs["model"])
|
||||
|
||||
if action == "post_clear":
|
||||
changed_queryset = model_manager.all()
|
||||
else:
|
||||
changed_queryset = model_manager.filter(pk__in=kwargs["pk_set"])
|
||||
|
||||
if action in ["post_add"]:
|
||||
LogEntry.objects.log_m2m_changes(
|
||||
changed_queryset,
|
||||
kwargs["instance"],
|
||||
"add",
|
||||
field_name,
|
||||
)
|
||||
elif action in ["post_remove", "post_clear"]:
|
||||
LogEntry.objects.log_m2m_changes(
|
||||
changed_queryset,
|
||||
kwargs["instance"],
|
||||
"delete",
|
||||
field_name,
|
||||
)
|
||||
|
||||
return log_m2m_changes
|
||||
385
auditlog/registry.py
Normal file
385
auditlog/registry.py
Normal file
|
|
@ -0,0 +1,385 @@
|
|||
import copy
|
||||
from collections import defaultdict
|
||||
from collections.abc import Callable, Collection, Iterable
|
||||
from typing import Any
|
||||
|
||||
from django.apps import apps
|
||||
from django.db.models import ManyToManyField, Model
|
||||
from django.db.models.base import ModelBase
|
||||
from django.db.models.signals import (
|
||||
ModelSignal,
|
||||
m2m_changed,
|
||||
post_delete,
|
||||
post_save,
|
||||
pre_save,
|
||||
)
|
||||
|
||||
from auditlog.conf import settings
|
||||
from auditlog.signals import accessed
|
||||
|
||||
DispatchUID = tuple[int, int, int]
|
||||
|
||||
|
||||
class AuditLogRegistrationError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class AuditlogModelRegistry:
|
||||
"""
|
||||
A registry that keeps track of the models that use Auditlog to track changes.
|
||||
"""
|
||||
|
||||
DEFAULT_EXCLUDE_MODELS = (settings.AUDITLOG_LOGENTRY_MODEL, "admin.LogEntry")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
create: bool = True,
|
||||
update: bool = True,
|
||||
delete: bool = True,
|
||||
access: bool = True,
|
||||
m2m: bool = True,
|
||||
custom: dict[ModelSignal, Callable] | None = None,
|
||||
):
|
||||
from auditlog.receivers import log_access, log_create, log_delete, log_update
|
||||
|
||||
self._registry = {}
|
||||
self._signals = {}
|
||||
self._m2m_signals = defaultdict(dict)
|
||||
|
||||
if create:
|
||||
self._signals[post_save] = log_create
|
||||
if update:
|
||||
self._signals[pre_save] = log_update
|
||||
if delete:
|
||||
self._signals[post_delete] = log_delete
|
||||
if access:
|
||||
self._signals[accessed] = log_access
|
||||
self._m2m = m2m
|
||||
|
||||
if custom is not None:
|
||||
self._signals.update(custom)
|
||||
|
||||
def register(
|
||||
self,
|
||||
model: ModelBase = None,
|
||||
include_fields: list[str] | None = None,
|
||||
exclude_fields: list[str] | None = None,
|
||||
mapping_fields: dict[str, str] | None = None,
|
||||
mask_fields: list[str] | None = None,
|
||||
mask_callable: str | None = None,
|
||||
m2m_fields: Collection[str] | None = None,
|
||||
serialize_data: bool = False,
|
||||
serialize_kwargs: dict[str, Any] | None = None,
|
||||
serialize_auditlog_fields_only: bool = False,
|
||||
):
|
||||
"""
|
||||
Register a model with auditlog. Auditlog will then track mutations on this model's instances.
|
||||
|
||||
:param model: The model to register.
|
||||
:param include_fields: The fields to include. Implicitly excludes all other fields.
|
||||
:param exclude_fields: The fields to exclude. Overrides the fields to include.
|
||||
:param mapping_fields: Mapping from field names to strings in diff.
|
||||
:param mask_fields: The fields to mask for sensitive info.
|
||||
:param mask_callable: The dotted path to a callable that will be used for masking. If not provided,
|
||||
the default mask_callable will be used.
|
||||
:param m2m_fields: The fields to handle as many to many.
|
||||
:param serialize_data: Option to include a dictionary of the objects state in the auditlog.
|
||||
:param serialize_kwargs: Optional kwargs to pass to Django serializer
|
||||
:param serialize_auditlog_fields_only: Only fields being considered in changes will be serialized.
|
||||
"""
|
||||
|
||||
if include_fields is None:
|
||||
include_fields = []
|
||||
if exclude_fields is None:
|
||||
exclude_fields = []
|
||||
if mapping_fields is None:
|
||||
mapping_fields = {}
|
||||
if mask_fields is None:
|
||||
mask_fields = []
|
||||
if m2m_fields is None:
|
||||
m2m_fields = set()
|
||||
if serialize_kwargs is None:
|
||||
serialize_kwargs = {}
|
||||
|
||||
if (serialize_kwargs or serialize_auditlog_fields_only) and not serialize_data:
|
||||
raise AuditLogRegistrationError(
|
||||
"Serializer options were given but the 'serialize_data' option is not "
|
||||
"set. Did you forget to set serialized_data to True?"
|
||||
)
|
||||
|
||||
for fld in settings.AUDITLOG_EXCLUDE_TRACKING_FIELDS:
|
||||
exclude_fields.append(fld)
|
||||
|
||||
for fld in settings.AUDITLOG_MASK_TRACKING_FIELDS:
|
||||
mask_fields.append(fld)
|
||||
|
||||
def registrar(cls):
|
||||
"""Register models for a given class."""
|
||||
if not issubclass(cls, Model):
|
||||
raise TypeError("Supplied model is not a valid model.")
|
||||
|
||||
self._registry[cls] = {
|
||||
"include_fields": include_fields,
|
||||
"exclude_fields": exclude_fields,
|
||||
"mapping_fields": mapping_fields,
|
||||
"mask_fields": mask_fields,
|
||||
"mask_callable": mask_callable,
|
||||
"m2m_fields": m2m_fields,
|
||||
"serialize_data": serialize_data,
|
||||
"serialize_kwargs": serialize_kwargs,
|
||||
"serialize_auditlog_fields_only": serialize_auditlog_fields_only,
|
||||
}
|
||||
self._connect_signals(cls)
|
||||
|
||||
# We need to return the class, as the decorator is basically
|
||||
# syntactic sugar for:
|
||||
# MyClass = auditlog.register(MyClass)
|
||||
return cls
|
||||
|
||||
if model is None:
|
||||
# If we're being used as a decorator, return a callable with the
|
||||
# wrapper.
|
||||
return lambda cls: registrar(cls)
|
||||
else:
|
||||
# Otherwise, just register the model.
|
||||
registrar(model)
|
||||
|
||||
def contains(self, model: ModelBase) -> bool:
|
||||
"""
|
||||
Check if a model is registered with auditlog.
|
||||
|
||||
:param model: The model to check.
|
||||
:return: Whether the model has been registered.
|
||||
:rtype: bool
|
||||
"""
|
||||
return model in self._registry
|
||||
|
||||
def unregister(self, model: ModelBase) -> None:
|
||||
"""
|
||||
Unregister a model with auditlog. This will not affect the database.
|
||||
|
||||
:param model: The model to unregister.
|
||||
"""
|
||||
try:
|
||||
del self._registry[model]
|
||||
except KeyError:
|
||||
pass
|
||||
else:
|
||||
self._disconnect_signals(model)
|
||||
|
||||
def get_models(self) -> list[ModelBase]:
|
||||
return list(self._registry.keys())
|
||||
|
||||
def get_model_fields(self, model: ModelBase):
|
||||
return {
|
||||
"include_fields": list(self._registry[model]["include_fields"]),
|
||||
"exclude_fields": list(self._registry[model]["exclude_fields"]),
|
||||
"mapping_fields": dict(self._registry[model]["mapping_fields"]),
|
||||
"mask_fields": list(self._registry[model]["mask_fields"]),
|
||||
"mask_callable": self._registry[model]["mask_callable"],
|
||||
}
|
||||
|
||||
def get_serialize_options(self, model: ModelBase):
|
||||
return {
|
||||
"serialize_data": bool(self._registry[model]["serialize_data"]),
|
||||
"serialize_kwargs": dict(self._registry[model]["serialize_kwargs"]),
|
||||
"serialize_auditlog_fields_only": bool(
|
||||
self._registry[model]["serialize_auditlog_fields_only"]
|
||||
),
|
||||
}
|
||||
|
||||
def _connect_signals(self, model):
|
||||
"""
|
||||
Connect signals for the model.
|
||||
"""
|
||||
from auditlog.receivers import make_log_m2m_changes
|
||||
|
||||
for signal, receiver in self._signals.items():
|
||||
signal.connect(
|
||||
receiver,
|
||||
sender=model,
|
||||
dispatch_uid=self._dispatch_uid(signal, receiver),
|
||||
)
|
||||
if self._m2m:
|
||||
for field_name in self._registry[model]["m2m_fields"]:
|
||||
receiver = make_log_m2m_changes(field_name)
|
||||
self._m2m_signals[model][field_name] = receiver
|
||||
field = getattr(model, field_name)
|
||||
m2m_model = getattr(field, "through")
|
||||
|
||||
m2m_changed.connect(
|
||||
receiver,
|
||||
sender=m2m_model,
|
||||
dispatch_uid=self._m2m_dispatch_uid(m2m_changed, m2m_model),
|
||||
)
|
||||
|
||||
def _disconnect_signals(self, model):
|
||||
"""
|
||||
Disconnect signals for the model.
|
||||
"""
|
||||
for signal, receiver in self._signals.items():
|
||||
signal.disconnect(
|
||||
sender=model, dispatch_uid=self._dispatch_uid(signal, receiver)
|
||||
)
|
||||
for field_name, receiver in self._m2m_signals[model].items():
|
||||
field = getattr(model, field_name)
|
||||
m2m_model = getattr(field, "through")
|
||||
m2m_changed.disconnect(
|
||||
sender=m2m_model,
|
||||
dispatch_uid=self._m2m_dispatch_uid(m2m_changed, m2m_model),
|
||||
)
|
||||
del self._m2m_signals[model]
|
||||
|
||||
def _dispatch_uid(self, signal, receiver) -> DispatchUID:
|
||||
"""Generate a dispatch_uid which is unique for a combination of self, signal, and receiver."""
|
||||
return id(self), id(signal), id(receiver)
|
||||
|
||||
def _m2m_dispatch_uid(self, signal, sender) -> DispatchUID:
|
||||
"""Generate a dispatch_uid which is unique for a combination of self, signal, and sender."""
|
||||
return id(self), id(signal), id(sender)
|
||||
|
||||
def _get_model_classes(self, app_model: str) -> list[ModelBase]:
|
||||
try:
|
||||
try:
|
||||
app_label, model_name = app_model.split(".")
|
||||
return [apps.get_model(app_label, model_name)]
|
||||
except ValueError:
|
||||
return apps.get_app_config(app_model).get_models()
|
||||
except LookupError:
|
||||
return []
|
||||
|
||||
def _get_exclude_models(
|
||||
self, exclude_tracking_models: Iterable[str]
|
||||
) -> list[ModelBase]:
|
||||
exclude_models = [
|
||||
model
|
||||
for app_model in tuple(exclude_tracking_models)
|
||||
+ self.DEFAULT_EXCLUDE_MODELS
|
||||
for model in self._get_model_classes(app_model)
|
||||
]
|
||||
return exclude_models
|
||||
|
||||
def _register_models(self, models: Iterable[str | dict[str, Any]]) -> None:
|
||||
models = copy.deepcopy(models)
|
||||
for model in models:
|
||||
if isinstance(model, str):
|
||||
for model_class in self._get_model_classes(model):
|
||||
self.unregister(model_class)
|
||||
self.register(model_class)
|
||||
elif isinstance(model, dict):
|
||||
appmodel = self._get_model_classes(model["model"])
|
||||
if not appmodel:
|
||||
raise AuditLogRegistrationError(
|
||||
f"An error was encountered while registering model '{model['model']}' - "
|
||||
"make sure the app is registered correctly."
|
||||
)
|
||||
model["model"] = appmodel[0]
|
||||
self.unregister(model["model"])
|
||||
self.register(**model)
|
||||
|
||||
def register_from_settings(self):
|
||||
"""
|
||||
Register models from settings variables
|
||||
"""
|
||||
if not isinstance(settings.AUDITLOG_INCLUDE_ALL_MODELS, bool):
|
||||
raise TypeError("Setting 'AUDITLOG_INCLUDE_ALL_MODELS' must be a boolean")
|
||||
if not isinstance(settings.AUDITLOG_DISABLE_ON_RAW_SAVE, bool):
|
||||
raise TypeError("Setting 'AUDITLOG_DISABLE_ON_RAW_SAVE' must be a boolean")
|
||||
if not isinstance(settings.AUDITLOG_EXCLUDE_TRACKING_MODELS, (list, tuple)):
|
||||
raise TypeError(
|
||||
"Setting 'AUDITLOG_EXCLUDE_TRACKING_MODELS' must be a list or tuple"
|
||||
)
|
||||
|
||||
if (
|
||||
not settings.AUDITLOG_INCLUDE_ALL_MODELS
|
||||
and settings.AUDITLOG_EXCLUDE_TRACKING_MODELS
|
||||
):
|
||||
raise ValueError(
|
||||
"In order to use setting 'AUDITLOG_EXCLUDE_TRACKING_MODELS', "
|
||||
"setting 'AUDITLOG_INCLUDE_ALL_MODELS' must set to 'True'"
|
||||
)
|
||||
|
||||
if (
|
||||
settings.AUDITLOG_EXCLUDE_TRACKING_FIELDS
|
||||
and not settings.AUDITLOG_INCLUDE_ALL_MODELS
|
||||
):
|
||||
raise ValueError(
|
||||
"In order to use 'AUDITLOG_EXCLUDE_TRACKING_FIELDS', "
|
||||
"setting 'AUDITLOG_INCLUDE_ALL_MODELS' must be set to 'True'"
|
||||
)
|
||||
|
||||
if (
|
||||
settings.AUDITLOG_MASK_TRACKING_FIELDS
|
||||
and not settings.AUDITLOG_INCLUDE_ALL_MODELS
|
||||
):
|
||||
raise ValueError(
|
||||
"In order to use 'AUDITLOG_MASK_TRACKING_FIELDS', "
|
||||
"setting 'AUDITLOG_INCLUDE_ALL_MODELS' must be set to 'True'"
|
||||
)
|
||||
|
||||
if not isinstance(settings.AUDITLOG_INCLUDE_TRACKING_MODELS, (list, tuple)):
|
||||
raise TypeError(
|
||||
"Setting 'AUDITLOG_INCLUDE_TRACKING_MODELS' must be a list or tuple"
|
||||
)
|
||||
|
||||
if not isinstance(settings.AUDITLOG_EXCLUDE_TRACKING_FIELDS, (list, tuple)):
|
||||
raise TypeError(
|
||||
"Setting 'AUDITLOG_EXCLUDE_TRACKING_FIELDS' must be a list or tuple"
|
||||
)
|
||||
|
||||
if not isinstance(settings.AUDITLOG_MASK_TRACKING_FIELDS, (list, tuple)):
|
||||
raise TypeError(
|
||||
"Setting 'AUDITLOG_MASK_TRACKING_FIELDS' must be a list or tuple"
|
||||
)
|
||||
|
||||
for item in settings.AUDITLOG_INCLUDE_TRACKING_MODELS:
|
||||
if not isinstance(item, (str, dict)):
|
||||
raise TypeError(
|
||||
"Setting 'AUDITLOG_INCLUDE_TRACKING_MODELS' items must be str or dict"
|
||||
)
|
||||
|
||||
if isinstance(item, dict):
|
||||
if "model" not in item:
|
||||
raise ValueError(
|
||||
"Setting 'AUDITLOG_INCLUDE_TRACKING_MODELS' dict items must contain 'model' key"
|
||||
)
|
||||
if "." not in item["model"]:
|
||||
raise ValueError(
|
||||
"Setting 'AUDITLOG_INCLUDE_TRACKING_MODELS' model must be in the "
|
||||
"format <app_name>.<model_name>"
|
||||
)
|
||||
|
||||
if settings.AUDITLOG_INCLUDE_ALL_MODELS:
|
||||
exclude_models = self._get_exclude_models(
|
||||
settings.AUDITLOG_EXCLUDE_TRACKING_MODELS
|
||||
)
|
||||
|
||||
for model in apps.get_models(include_auto_created=True):
|
||||
if model in exclude_models:
|
||||
continue
|
||||
|
||||
meta = model._meta
|
||||
if not meta.managed:
|
||||
continue
|
||||
|
||||
m2m_fields = [
|
||||
m.name for m in meta.get_fields() if isinstance(m, ManyToManyField)
|
||||
]
|
||||
|
||||
exclude_fields = [
|
||||
i.related_name
|
||||
for i in meta.related_objects
|
||||
if i.related_name and not i.related_model._meta.managed
|
||||
]
|
||||
|
||||
self.register(
|
||||
model=model, m2m_fields=m2m_fields, exclude_fields=exclude_fields
|
||||
)
|
||||
|
||||
if not isinstance(settings.AUDITLOG_STORE_JSON_CHANGES, bool):
|
||||
raise TypeError("Setting 'AUDITLOG_STORE_JSON_CHANGES' must be a boolean")
|
||||
|
||||
self._register_models(settings.AUDITLOG_INCLUDE_TRACKING_MODELS)
|
||||
|
||||
|
||||
auditlog = AuditlogModelRegistry()
|
||||
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)
|
||||
70
auditlog/signals.py
Normal file
70
auditlog/signals.py
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import django.dispatch
|
||||
|
||||
accessed = django.dispatch.Signal()
|
||||
|
||||
|
||||
pre_log = django.dispatch.Signal()
|
||||
"""
|
||||
Whenever an audit log entry is written, this signal
|
||||
is sent before writing the log.
|
||||
Keyword arguments sent with this signal:
|
||||
|
||||
:param class sender:
|
||||
The model class that's being audited.
|
||||
|
||||
:param Any instance:
|
||||
The actual instance that's being audited.
|
||||
|
||||
:param Action action:
|
||||
The action on the model resulting in an
|
||||
audit log entry. Type: :class:`auditlog.models.LogEntry.Action`
|
||||
|
||||
The receivers' return values are sent to any :func:`post_log`
|
||||
signal receivers, with one exception: if any receiver returns False,
|
||||
no logging will be made. This can be useful if logging should be
|
||||
conditionally enabled / disabled
|
||||
"""
|
||||
|
||||
post_log = django.dispatch.Signal()
|
||||
"""
|
||||
Whenever an audit log entry is written, this signal
|
||||
is sent after writing the log.
|
||||
This signal is also fired when there is an error in creating the log.
|
||||
|
||||
Keyword arguments sent with this signal:
|
||||
|
||||
:param class sender:
|
||||
The model class that's being audited.
|
||||
|
||||
:param Any instance:
|
||||
The actual instance that's being audited.
|
||||
|
||||
:param Action action:
|
||||
The action on the model resulting in an
|
||||
audit log entry. Type: :class:`auditlog.models.LogEntry.Action`
|
||||
|
||||
:param Optional[dict] changes:
|
||||
The changes that were logged. If there was en error while determining the changes,
|
||||
this will be None. In some cases, such as when logging access to the instance,
|
||||
the changes will be an empty dict.
|
||||
|
||||
:param Optional[LogEntry] log_entry:
|
||||
The log entry that was created and stored in the database. If there was an error,
|
||||
this will be None.
|
||||
|
||||
:param bool log_created:
|
||||
Was the log actually created?
|
||||
This could be false if there was an error in creating the log.
|
||||
|
||||
:param Optional[Exception] error:
|
||||
The error, if one occurred while saving the audit log entry. ``None``,
|
||||
otherwise
|
||||
|
||||
:param List[Tuple[method,Any]] pre_log_results:
|
||||
List of tuple pairs ``[(pre_log_receiver, pre_log_response)]``, where
|
||||
``pre_log_receiver`` is the receiver method, and ``pre_log_response`` is the
|
||||
corresponding response of that method. If there are no :const:`pre_log` receivers,
|
||||
then the list will be empty. ``pre_log_receiver`` is guaranteed to be
|
||||
non-null, but ``pre_log_response`` may be ``None``. This depends on the corresponding
|
||||
``pre_log_receiver``'s return value.
|
||||
"""
|
||||
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>
|
||||
0
auditlog/templatetags/__init__.py
Normal file
0
auditlog/templatetags/__init__.py
Normal file
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/__init__.py
Normal file
0
auditlog_tests/__init__.py
Normal file
6
auditlog_tests/admin.py
Normal file
6
auditlog_tests/admin.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
from django.contrib import admin
|
||||
|
||||
from auditlog.registry import auditlog
|
||||
|
||||
for model in auditlog.get_models():
|
||||
admin.site.register(model)
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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):
|
||||
name = 'auditlog_tests'
|
||||
name = "test_app"
|
||||
2
auditlog_tests/test_app/fixtures/custom_get_cid.py
Normal file
2
auditlog_tests/test_app/fixtures/custom_get_cid.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
def get_cid():
|
||||
return "my custom get_cid"
|
||||
15
auditlog_tests/test_app/fixtures/m2m_test_fixture.json
Normal file
15
auditlog_tests/test_app/fixtures/m2m_test_fixture.json
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
[
|
||||
{
|
||||
"model": "test_app.manyrelatedmodel",
|
||||
"pk": 1,
|
||||
"fields": {
|
||||
"recursive": [1],
|
||||
"related": [1]
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "test_app.manyrelatedothermodel",
|
||||
"pk": 1,
|
||||
"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
|
||||
532
auditlog_tests/test_app/models.py
Normal file
532
auditlog_tests/test_app/models.py
Normal file
|
|
@ -0,0 +1,532 @@
|
|||
import uuid
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.db import models
|
||||
|
||||
from auditlog.models import AuditlogHistoryField
|
||||
from auditlog.registry import AuditlogModelRegistry, auditlog
|
||||
|
||||
m2m_only_auditlog = AuditlogModelRegistry(create=False, update=False, delete=False)
|
||||
|
||||
|
||||
@auditlog.register()
|
||||
class SimpleModel(models.Model):
|
||||
"""
|
||||
A simple model with no special things going on.
|
||||
"""
|
||||
|
||||
text = models.TextField(blank=True)
|
||||
boolean = models.BooleanField(default=False)
|
||||
integer = models.IntegerField(blank=True, null=True)
|
||||
datetime = models.DateTimeField(auto_now=True)
|
||||
char = models.CharField(null=True, max_length=100, default=lambda: "default value")
|
||||
|
||||
history = AuditlogHistoryField(delete_related=True)
|
||||
|
||||
def __str__(self):
|
||||
return str(self.text)
|
||||
|
||||
|
||||
class AltPrimaryKeyModel(models.Model):
|
||||
"""
|
||||
A model with a non-standard primary key.
|
||||
"""
|
||||
|
||||
key = models.CharField(max_length=100, primary_key=True)
|
||||
|
||||
text = models.TextField(blank=True)
|
||||
boolean = models.BooleanField(default=False)
|
||||
integer = models.IntegerField(blank=True, null=True)
|
||||
datetime = models.DateTimeField(auto_now=True)
|
||||
|
||||
history = AuditlogHistoryField(delete_related=True, pk_indexable=False)
|
||||
|
||||
|
||||
class UUIDPrimaryKeyModel(models.Model):
|
||||
"""
|
||||
A model with a UUID primary key.
|
||||
"""
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
|
||||
text = models.TextField(blank=True)
|
||||
boolean = models.BooleanField(default=False)
|
||||
integer = models.IntegerField(blank=True, null=True)
|
||||
datetime = models.DateTimeField(auto_now=True)
|
||||
|
||||
history = AuditlogHistoryField(delete_related=True, pk_indexable=False)
|
||||
|
||||
|
||||
class ModelPrimaryKeyModel(models.Model):
|
||||
"""
|
||||
A model with another model as primary key.
|
||||
"""
|
||||
|
||||
key = models.OneToOneField(
|
||||
"SimpleModel",
|
||||
primary_key=True,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="reverse_primary_key",
|
||||
)
|
||||
|
||||
text = models.TextField(blank=True)
|
||||
boolean = models.BooleanField(default=False)
|
||||
integer = models.IntegerField(blank=True, null=True)
|
||||
datetime = models.DateTimeField(auto_now=True)
|
||||
|
||||
history = AuditlogHistoryField(delete_related=True, pk_indexable=False)
|
||||
|
||||
|
||||
class ProxyModel(SimpleModel):
|
||||
"""
|
||||
A model that is a proxy for another model.
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
proxy = True
|
||||
|
||||
|
||||
class RelatedModelParent(models.Model):
|
||||
"""
|
||||
Use multi table inheritance to make a OneToOneRel field
|
||||
"""
|
||||
|
||||
|
||||
class RelatedModel(RelatedModelParent):
|
||||
"""
|
||||
A model with a foreign key.
|
||||
"""
|
||||
|
||||
related = models.ForeignKey(
|
||||
"SimpleModel", related_name="related_models", on_delete=models.CASCADE
|
||||
)
|
||||
one_to_one = models.OneToOneField(
|
||||
to="SimpleModel", on_delete=models.CASCADE, related_name="reverse_one_to_one"
|
||||
)
|
||||
|
||||
history = AuditlogHistoryField(delete_related=True)
|
||||
|
||||
def __str__(self):
|
||||
return f"RelatedModel #{self.pk} -> {self.related.id}"
|
||||
|
||||
|
||||
class ManyRelatedModel(models.Model):
|
||||
"""
|
||||
A model with many-to-many relations.
|
||||
"""
|
||||
|
||||
recursive = models.ManyToManyField("self")
|
||||
related = models.ManyToManyField("ManyRelatedOtherModel", related_name="related")
|
||||
|
||||
history = AuditlogHistoryField(delete_related=True)
|
||||
|
||||
def get_additional_data(self):
|
||||
related = self.related.first()
|
||||
return {"related_model_id": related.id if related else None}
|
||||
|
||||
|
||||
class ManyRelatedOtherModel(models.Model):
|
||||
"""
|
||||
A model related to ManyRelatedModel as many-to-many.
|
||||
"""
|
||||
|
||||
history = AuditlogHistoryField(delete_related=True)
|
||||
|
||||
|
||||
class ReusableThroughRelatedModel(models.Model):
|
||||
"""
|
||||
A model related to multiple other models through a model.
|
||||
"""
|
||||
|
||||
label = models.CharField(max_length=100)
|
||||
|
||||
|
||||
class ReusableThroughModel(models.Model):
|
||||
"""
|
||||
A through model that can be associated multiple different models.
|
||||
"""
|
||||
|
||||
label = models.ForeignKey(
|
||||
ReusableThroughRelatedModel,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="%(app_label)s_%(class)s_items",
|
||||
)
|
||||
one = models.ForeignKey(
|
||||
"ModelForReusableThroughModel", on_delete=models.CASCADE, null=True, blank=True
|
||||
)
|
||||
two = models.ForeignKey(
|
||||
"OtherModelForReusableThroughModel",
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
|
||||
|
||||
class ModelForReusableThroughModel(models.Model):
|
||||
"""
|
||||
A model with many-to-many relations through a shared model.
|
||||
"""
|
||||
|
||||
name = models.CharField(max_length=200)
|
||||
related = models.ManyToManyField(
|
||||
ReusableThroughRelatedModel, through=ReusableThroughModel
|
||||
)
|
||||
|
||||
history = AuditlogHistoryField(delete_related=True)
|
||||
|
||||
|
||||
class OtherModelForReusableThroughModel(models.Model):
|
||||
"""
|
||||
Another model with many-to-many relations through a shared model.
|
||||
"""
|
||||
|
||||
name = models.CharField(max_length=200)
|
||||
related = models.ManyToManyField(
|
||||
ReusableThroughRelatedModel, through=ReusableThroughModel
|
||||
)
|
||||
|
||||
history = AuditlogHistoryField(delete_related=True)
|
||||
|
||||
|
||||
@auditlog.register(include_fields=["label"])
|
||||
class SimpleIncludeModel(models.Model):
|
||||
"""
|
||||
A simple model used for register's include_fields kwarg
|
||||
"""
|
||||
|
||||
label = models.CharField(max_length=100)
|
||||
text = models.TextField(blank=True)
|
||||
|
||||
history = AuditlogHistoryField(delete_related=True)
|
||||
|
||||
|
||||
class SimpleExcludeModel(models.Model):
|
||||
"""
|
||||
A simple model used for register's exclude_fields kwarg
|
||||
"""
|
||||
|
||||
label = models.CharField(max_length=100)
|
||||
text = models.TextField(blank=True)
|
||||
|
||||
history = AuditlogHistoryField(delete_related=True)
|
||||
|
||||
|
||||
class SimpleMappingModel(models.Model):
|
||||
"""
|
||||
A simple model used for register's mapping_fields kwarg
|
||||
"""
|
||||
|
||||
sku = models.CharField(max_length=100)
|
||||
vtxt = models.CharField(verbose_name="Version", max_length=100)
|
||||
not_mapped = models.CharField(max_length=100)
|
||||
|
||||
history = AuditlogHistoryField(delete_related=True)
|
||||
|
||||
|
||||
@auditlog.register(mask_fields=["address"])
|
||||
class SimpleMaskedModel(models.Model):
|
||||
"""
|
||||
A simple model used for register's mask_fields kwarg
|
||||
"""
|
||||
|
||||
address = models.CharField(max_length=100)
|
||||
text = models.TextField()
|
||||
|
||||
history = AuditlogHistoryField(delete_related=True)
|
||||
|
||||
|
||||
class AdditionalDataIncludedModel(models.Model):
|
||||
"""
|
||||
A model where get_additional_data is defined which allows for logging extra
|
||||
information about the model in JSON
|
||||
"""
|
||||
|
||||
label = models.CharField(max_length=100)
|
||||
text = models.TextField(blank=True)
|
||||
related = models.ForeignKey(to=SimpleModel, on_delete=models.CASCADE)
|
||||
|
||||
history = AuditlogHistoryField(delete_related=True)
|
||||
|
||||
def get_additional_data(self):
|
||||
"""
|
||||
Returns JSON that captures a snapshot of additional details of the
|
||||
model instance. This method, if defined, is accessed by auditlog
|
||||
manager and added to each logentry instance on creation.
|
||||
"""
|
||||
object_details = {
|
||||
"related_model_id": self.related.id,
|
||||
"related_model_text": self.related.text,
|
||||
}
|
||||
return object_details
|
||||
|
||||
|
||||
class DateTimeFieldModel(models.Model):
|
||||
"""
|
||||
A model with a DateTimeField, used to test DateTimeField
|
||||
changes are detected properly.
|
||||
"""
|
||||
|
||||
label = models.CharField(max_length=100)
|
||||
timestamp = models.DateTimeField()
|
||||
date = models.DateField()
|
||||
time = models.TimeField()
|
||||
naive_dt = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
history = AuditlogHistoryField(delete_related=True)
|
||||
|
||||
|
||||
class ChoicesFieldModel(models.Model):
|
||||
"""
|
||||
A model with a CharField restricted to a set of choices.
|
||||
This model is used to test the changes_display_dict method.
|
||||
"""
|
||||
|
||||
RED = "r"
|
||||
YELLOW = "y"
|
||||
GREEN = "g"
|
||||
|
||||
STATUS_CHOICES = (
|
||||
(RED, "Red"),
|
||||
(YELLOW, "Yellow"),
|
||||
(GREEN, "Green"),
|
||||
)
|
||||
|
||||
status = models.CharField(max_length=1, choices=STATUS_CHOICES)
|
||||
multiplechoice = models.CharField(max_length=255, choices=STATUS_CHOICES)
|
||||
|
||||
history = AuditlogHistoryField(delete_related=True)
|
||||
|
||||
|
||||
class CharfieldTextfieldModel(models.Model):
|
||||
"""
|
||||
A model with a max length CharField and a Textfield.
|
||||
This model is used to test the changes_display_dict
|
||||
method's ability to truncate long text.
|
||||
"""
|
||||
|
||||
longchar = models.CharField(max_length=255)
|
||||
longtextfield = models.TextField()
|
||||
|
||||
history = AuditlogHistoryField(delete_related=True)
|
||||
|
||||
|
||||
# Only define PostgreSQL-specific models when ArrayField is available
|
||||
if settings.TEST_DB_BACKEND == "postgresql":
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
|
||||
class PostgresArrayFieldModel(models.Model):
|
||||
"""
|
||||
Test auditlog with Postgres's ArrayField
|
||||
"""
|
||||
|
||||
RED = "r"
|
||||
YELLOW = "y"
|
||||
GREEN = "g"
|
||||
|
||||
STATUS_CHOICES = (
|
||||
(RED, "Red"),
|
||||
(YELLOW, "Yellow"),
|
||||
(GREEN, "Green"),
|
||||
)
|
||||
|
||||
arrayfield = ArrayField(
|
||||
models.CharField(max_length=1, choices=STATUS_CHOICES), size=3
|
||||
)
|
||||
|
||||
history = AuditlogHistoryField(delete_related=True)
|
||||
|
||||
else:
|
||||
|
||||
class PostgresArrayFieldModel(models.Model):
|
||||
class Meta:
|
||||
managed = False
|
||||
|
||||
|
||||
class NoDeleteHistoryModel(models.Model):
|
||||
integer = models.IntegerField(blank=True, null=True)
|
||||
|
||||
history = AuditlogHistoryField(delete_related=False)
|
||||
|
||||
|
||||
class JSONModel(models.Model):
|
||||
json = models.JSONField(default=dict, encoder=DjangoJSONEncoder)
|
||||
|
||||
history = AuditlogHistoryField(delete_related=False)
|
||||
|
||||
|
||||
class NullableJSONModel(models.Model):
|
||||
json = models.JSONField(null=True, blank=True)
|
||||
|
||||
history = AuditlogHistoryField(delete_related=False)
|
||||
|
||||
|
||||
class SerializeThisModel(models.Model):
|
||||
label = models.CharField(max_length=24, unique=True)
|
||||
timestamp = models.DateTimeField()
|
||||
nullable = models.IntegerField(null=True)
|
||||
nested = models.JSONField()
|
||||
mask_me = models.CharField(max_length=255, null=True)
|
||||
code = models.UUIDField(null=True)
|
||||
date = models.DateField(null=True)
|
||||
|
||||
history = AuditlogHistoryField(delete_related=False)
|
||||
|
||||
def natural_key(self):
|
||||
return self.label
|
||||
|
||||
|
||||
class SerializeOnlySomeOfThisModel(models.Model):
|
||||
this = models.CharField(max_length=24)
|
||||
not_this = models.CharField(max_length=24)
|
||||
|
||||
history = AuditlogHistoryField(delete_related=False)
|
||||
|
||||
|
||||
class SerializePrimaryKeyRelatedModel(models.Model):
|
||||
serialize_this = models.ForeignKey(to=SerializeThisModel, on_delete=models.CASCADE)
|
||||
subheading = models.CharField(max_length=255)
|
||||
value = models.IntegerField()
|
||||
|
||||
history = AuditlogHistoryField(delete_related=False)
|
||||
|
||||
|
||||
class SerializeNaturalKeyRelatedModel(models.Model):
|
||||
serialize_this = models.ForeignKey(to=SerializeThisModel, on_delete=models.CASCADE)
|
||||
subheading = models.CharField(max_length=255)
|
||||
value = models.IntegerField()
|
||||
|
||||
history = AuditlogHistoryField(delete_related=False)
|
||||
|
||||
|
||||
class SimpleNonManagedModel(models.Model):
|
||||
"""
|
||||
A simple model with no special things going on.
|
||||
"""
|
||||
|
||||
text = models.TextField(blank=True)
|
||||
boolean = models.BooleanField(default=False)
|
||||
integer = models.IntegerField(blank=True, null=True)
|
||||
datetime = models.DateTimeField(auto_now=True)
|
||||
|
||||
history = AuditlogHistoryField(delete_related=True)
|
||||
|
||||
def __str__(self):
|
||||
return self.text
|
||||
|
||||
class Meta:
|
||||
managed = False
|
||||
|
||||
|
||||
class SecretManager(models.Manager):
|
||||
def get_queryset(self):
|
||||
return super().get_queryset().filter(is_secret=False)
|
||||
|
||||
|
||||
@auditlog.register()
|
||||
class SwappedManagerModel(models.Model):
|
||||
is_secret = models.BooleanField(default=False)
|
||||
name = models.CharField(max_length=255)
|
||||
|
||||
objects = SecretManager()
|
||||
|
||||
def __str__(self):
|
||||
return str(self.name)
|
||||
|
||||
|
||||
@auditlog.register()
|
||||
class SecretRelatedModel(RelatedModelParent):
|
||||
"""
|
||||
A RelatedModel, but with a foreign key to an object that could be secret.
|
||||
"""
|
||||
|
||||
related = models.ForeignKey(
|
||||
"SwappedManagerModel", related_name="related_models", on_delete=models.CASCADE
|
||||
)
|
||||
one_to_one = models.OneToOneField(
|
||||
to="SwappedManagerModel",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="reverse_one_to_one",
|
||||
)
|
||||
|
||||
history = AuditlogHistoryField(delete_related=True)
|
||||
|
||||
def __str__(self):
|
||||
return f"SecretRelatedModel #{self.pk} -> {self.related.id}"
|
||||
|
||||
|
||||
class SecretM2MModel(models.Model):
|
||||
m2m_related = models.ManyToManyField(
|
||||
"SwappedManagerModel", related_name="m2m_related"
|
||||
)
|
||||
name = models.CharField(max_length=255)
|
||||
|
||||
def __str__(self):
|
||||
return str(self.name)
|
||||
|
||||
|
||||
class AutoManyRelatedModel(models.Model):
|
||||
related = models.ManyToManyField(SimpleModel)
|
||||
|
||||
|
||||
class CustomMaskModel(models.Model):
|
||||
credit_card = models.CharField(max_length=16)
|
||||
text = models.TextField()
|
||||
|
||||
history = AuditlogHistoryField(delete_related=True)
|
||||
|
||||
|
||||
class NullableFieldModel(models.Model):
|
||||
time = models.TimeField(null=True, blank=True)
|
||||
optional_text = models.CharField(max_length=100, null=True, blank=True)
|
||||
|
||||
history = AuditlogHistoryField(delete_related=True)
|
||||
|
||||
|
||||
auditlog.register(AltPrimaryKeyModel)
|
||||
auditlog.register(UUIDPrimaryKeyModel)
|
||||
auditlog.register(ModelPrimaryKeyModel)
|
||||
auditlog.register(ProxyModel)
|
||||
auditlog.register(RelatedModelParent)
|
||||
auditlog.register(RelatedModel)
|
||||
auditlog.register(ManyRelatedModel)
|
||||
auditlog.register(ManyRelatedModel.recursive.through)
|
||||
m2m_only_auditlog.register(ManyRelatedModel, m2m_fields={"related"})
|
||||
m2m_only_auditlog.register(ModelForReusableThroughModel, m2m_fields={"related"})
|
||||
m2m_only_auditlog.register(OtherModelForReusableThroughModel, m2m_fields={"related"})
|
||||
m2m_only_auditlog.register(SecretM2MModel, m2m_fields={"m2m_related"})
|
||||
m2m_only_auditlog.register(SwappedManagerModel, m2m_fields={"m2m_related"})
|
||||
auditlog.register(SimpleExcludeModel, exclude_fields=["text"])
|
||||
auditlog.register(SimpleMappingModel, mapping_fields={"sku": "Product No."})
|
||||
auditlog.register(AdditionalDataIncludedModel)
|
||||
auditlog.register(DateTimeFieldModel)
|
||||
auditlog.register(ChoicesFieldModel)
|
||||
auditlog.register(CharfieldTextfieldModel)
|
||||
if settings.TEST_DB_BACKEND == "postgresql":
|
||||
auditlog.register(PostgresArrayFieldModel)
|
||||
auditlog.register(NoDeleteHistoryModel)
|
||||
auditlog.register(JSONModel)
|
||||
auditlog.register(NullableJSONModel)
|
||||
auditlog.register(
|
||||
SerializeThisModel,
|
||||
serialize_data=True,
|
||||
mask_fields=["mask_me"],
|
||||
)
|
||||
auditlog.register(
|
||||
SerializeOnlySomeOfThisModel,
|
||||
serialize_data=True,
|
||||
serialize_auditlog_fields_only=True,
|
||||
exclude_fields=["not_this"],
|
||||
)
|
||||
auditlog.register(SerializePrimaryKeyRelatedModel, serialize_data=True)
|
||||
auditlog.register(
|
||||
SerializeNaturalKeyRelatedModel,
|
||||
serialize_data=True,
|
||||
serialize_kwargs={"use_natural_foreign_keys": True},
|
||||
)
|
||||
auditlog.register(
|
||||
CustomMaskModel,
|
||||
mask_fields=["credit_card"],
|
||||
mask_callable="auditlog_tests.test_app.mask.custom_mask_str",
|
||||
)
|
||||
auditlog.register(NullableFieldModel)
|
||||
13
auditlog_tests/test_app/urls.py
Normal file
13
auditlog_tests/test_app/urls.py
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
from django.contrib import admin
|
||||
from django.urls import path
|
||||
|
||||
from .views import SimpleModelDetailView
|
||||
|
||||
urlpatterns = [
|
||||
path("admin/", admin.site.urls),
|
||||
path(
|
||||
"simplemodel/<int:pk>/",
|
||||
SimpleModelDetailView.as_view(),
|
||||
name="simplemodel-detail",
|
||||
),
|
||||
]
|
||||
10
auditlog_tests/test_app/views.py
Normal file
10
auditlog_tests/test_app/views.py
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
from django.views.generic import DetailView
|
||||
|
||||
from auditlog.mixins import LogAccessMixin
|
||||
|
||||
from .models import SimpleModel
|
||||
|
||||
|
||||
class SimpleModelDetailView(LogAccessMixin, DetailView):
|
||||
model = SimpleModel
|
||||
template_name = "simplemodel_detail.html"
|
||||
205
auditlog_tests/test_commands.py
Normal file
205
auditlog_tests/test_commands.py
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
"""Tests for auditlog.management.commands"""
|
||||
|
||||
import datetime
|
||||
from io import StringIO
|
||||
from unittest import mock
|
||||
|
||||
import freezegun
|
||||
from django.core.management import call_command
|
||||
from django.db import connection
|
||||
from django.test import TestCase, TransactionTestCase
|
||||
from django.test.utils import skipIf
|
||||
from test_app.models import SimpleModel
|
||||
|
||||
from auditlog.management.commands.auditlogflush import TruncateQuery
|
||||
|
||||
|
||||
class AuditlogFlushTest(TestCase):
|
||||
def setUp(self):
|
||||
input_patcher = mock.patch("builtins.input")
|
||||
self.mock_input = input_patcher.start()
|
||||
self.addCleanup(input_patcher.stop)
|
||||
|
||||
def make_object(self):
|
||||
return SimpleModel.objects.create(text="I am a simple model.")
|
||||
|
||||
def call_command(self, *args, **kwargs):
|
||||
outbuf = StringIO()
|
||||
errbuf = StringIO()
|
||||
call_command("auditlogflush", *args, stdout=outbuf, stderr=errbuf, **kwargs)
|
||||
return outbuf.getvalue().strip(), errbuf.getvalue().strip()
|
||||
|
||||
def test_flush_yes(self):
|
||||
obj = self.make_object()
|
||||
self.assertEqual(obj.history.count(), 1, msg="There is one log entry.")
|
||||
|
||||
out, err = self.call_command("--yes")
|
||||
|
||||
self.assertEqual(obj.history.count(), 0, msg="There are no log entries.")
|
||||
self.assertEqual(
|
||||
out, "Deleted 1 objects.", msg="Output shows deleted 1 object."
|
||||
)
|
||||
self.assertEqual(err, "", msg="No stderr")
|
||||
|
||||
def test_flush_no(self):
|
||||
obj = self.make_object()
|
||||
self.assertEqual(obj.history.count(), 1, msg="There is one log entry.")
|
||||
|
||||
self.mock_input.return_value = "N\n"
|
||||
out, err = self.call_command()
|
||||
|
||||
self.assertEqual(obj.history.count(), 1, msg="There is still one log entry.")
|
||||
self.assertEqual(
|
||||
out,
|
||||
"This action will clear all log entries from the database.\nAborted.",
|
||||
msg="Output shows warning and aborted.",
|
||||
)
|
||||
self.assertEqual(err, "", msg="No stderr")
|
||||
|
||||
def test_flush_input_yes(self):
|
||||
obj = self.make_object()
|
||||
self.assertEqual(obj.history.count(), 1, msg="There is one log entry.")
|
||||
|
||||
self.mock_input.return_value = "Y\n"
|
||||
out, err = self.call_command()
|
||||
|
||||
self.assertEqual(obj.history.count(), 0, msg="There are no log entries.")
|
||||
self.assertEqual(
|
||||
out,
|
||||
"This action will clear all log entries from the database.\nDeleted 1 objects.",
|
||||
msg="Output shows warning and deleted 1 object.",
|
||||
)
|
||||
self.assertEqual(err, "", msg="No stderr")
|
||||
|
||||
def test_before_date_input(self):
|
||||
self.mock_input.return_value = "N\n"
|
||||
out, err = self.call_command("--before-date=2000-01-01")
|
||||
self.assertEqual(
|
||||
out,
|
||||
(
|
||||
"This action will clear all log entries before "
|
||||
"2000-01-01 from the database.\nAborted."
|
||||
),
|
||||
msg="Output shows warning with date and then aborted.",
|
||||
)
|
||||
self.assertEqual(err, "", msg="No stderr")
|
||||
|
||||
def test_before_date(self):
|
||||
with freezegun.freeze_time("1999-12-31"):
|
||||
obj = self.make_object()
|
||||
|
||||
with freezegun.freeze_time("2000-01-02"):
|
||||
obj.text = "I have new text"
|
||||
obj.save()
|
||||
|
||||
self.assertEqual(
|
||||
{v["timestamp"] for v in obj.history.values("timestamp")},
|
||||
{
|
||||
datetime.datetime(1999, 12, 31, tzinfo=datetime.timezone.utc),
|
||||
datetime.datetime(2000, 1, 2, tzinfo=datetime.timezone.utc),
|
||||
},
|
||||
msg="Entries exist for 1999-12-31 and 2000-01-02",
|
||||
)
|
||||
|
||||
out, err = self.call_command("--yes", "--before-date=2000-01-01")
|
||||
self.assertEqual(
|
||||
{v["timestamp"] for v in obj.history.values("timestamp")},
|
||||
{
|
||||
datetime.datetime(2000, 1, 2, tzinfo=datetime.timezone.utc),
|
||||
},
|
||||
msg="An entry exists only for 2000-01-02",
|
||||
)
|
||||
self.assertEqual(
|
||||
out, "Deleted 1 objects.", msg="Output shows deleted 1 object."
|
||||
)
|
||||
self.assertEqual(err, "", msg="No stderr")
|
||||
|
||||
|
||||
class AuditlogFlushWithTruncateTest(TransactionTestCase):
|
||||
def setUp(self):
|
||||
input_patcher = mock.patch("builtins.input")
|
||||
self.mock_input = input_patcher.start()
|
||||
self.addCleanup(input_patcher.stop)
|
||||
|
||||
def _fixture_teardown(self):
|
||||
call_command("flush", verbosity=0, interactive=False, allow_cascade=True)
|
||||
|
||||
def make_object(self):
|
||||
return SimpleModel.objects.create(text="I am a simple model.")
|
||||
|
||||
def call_command(self, *args, **kwargs):
|
||||
outbuf = StringIO()
|
||||
errbuf = StringIO()
|
||||
call_command("auditlogflush", *args, stdout=outbuf, stderr=errbuf, **kwargs)
|
||||
return outbuf.getvalue().strip(), errbuf.getvalue().strip()
|
||||
|
||||
def test_flush_with_both_truncate_and_before_date_options(self):
|
||||
obj = self.make_object()
|
||||
self.assertEqual(obj.history.count(), 1, msg="There is one log entry.")
|
||||
out, err = self.call_command("--truncate", "--before-date=2000-01-01")
|
||||
|
||||
self.assertEqual(obj.history.count(), 1, msg="There is still one log entry.")
|
||||
self.assertEqual(
|
||||
out,
|
||||
"Truncate deletes all log entries and can not be passed with before-date.",
|
||||
msg="Output shows error",
|
||||
)
|
||||
self.assertEqual(err, "", msg="No stderr")
|
||||
|
||||
@skipIf(
|
||||
not TruncateQuery.support_truncate_statement(connection.vendor),
|
||||
"Database does not support TRUNCATE",
|
||||
)
|
||||
def test_flush_with_truncate_and_yes(self):
|
||||
obj = self.make_object()
|
||||
self.assertEqual(obj.history.count(), 1, msg="There is one log entry.")
|
||||
out, err = self.call_command("--truncate", "--y")
|
||||
|
||||
self.assertEqual(obj.history.count(), 0, msg="There is no log entry.")
|
||||
self.assertEqual(
|
||||
out,
|
||||
"Truncated log entry table.",
|
||||
msg="Output shows table gets truncate",
|
||||
)
|
||||
self.assertEqual(err, "", msg="No stderr")
|
||||
|
||||
@skipIf(
|
||||
not TruncateQuery.support_truncate_statement(connection.vendor),
|
||||
"Database does not support TRUNCATE",
|
||||
)
|
||||
def test_flush_with_truncate_with_input_yes(self):
|
||||
obj = self.make_object()
|
||||
self.assertEqual(obj.history.count(), 1, msg="There is one log entry.")
|
||||
self.mock_input.return_value = "Y\n"
|
||||
out, err = self.call_command("--truncate")
|
||||
|
||||
self.assertEqual(obj.history.count(), 0, msg="There is no log entry.")
|
||||
self.assertEqual(
|
||||
out,
|
||||
"This action will clear all log entries from the database.\nTruncated log entry table.",
|
||||
msg="Output shows warning and table gets truncate",
|
||||
)
|
||||
self.assertEqual(err, "", msg="No stderr")
|
||||
|
||||
@mock.patch(
|
||||
"django.db.connection.vendor",
|
||||
new_callable=mock.PropertyMock(return_value="unknown"),
|
||||
)
|
||||
@mock.patch(
|
||||
"django.db.connection.display_name",
|
||||
new_callable=mock.PropertyMock(return_value="Unknown"),
|
||||
)
|
||||
def test_flush_with_truncate_for_unsupported_database_vendor(
|
||||
self, mocked_vendor, mocked_db_name
|
||||
):
|
||||
obj = self.make_object()
|
||||
self.assertEqual(obj.history.count(), 1, msg="There is one log entry.")
|
||||
out, err = self.call_command("--truncate", "--y")
|
||||
|
||||
self.assertEqual(obj.history.count(), 1, msg="There is still one log entry.")
|
||||
self.assertEqual(
|
||||
out,
|
||||
"Database Unknown does not support truncate statement.",
|
||||
msg="Output shows error",
|
||||
)
|
||||
self.assertEqual(err, "", msg="No stderr")
|
||||
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)
|
||||
110
auditlog_tests/test_settings.py
Normal file
110
auditlog_tests/test_settings.py
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
"""
|
||||
Settings file for the Auditlog test suite.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
DEBUG = True
|
||||
|
||||
SECRET_KEY = "test"
|
||||
|
||||
TEST_DB_BACKEND = os.getenv("TEST_DB_BACKEND", "sqlite3")
|
||||
|
||||
INSTALLED_APPS = [
|
||||
"django.contrib.auth",
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.messages",
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.admin",
|
||||
"django.contrib.staticfiles",
|
||||
"django.contrib.postgres",
|
||||
"custom_logentry_app",
|
||||
"auditlog",
|
||||
"test_app",
|
||||
]
|
||||
|
||||
|
||||
MIDDLEWARE = [
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
]
|
||||
|
||||
if os.environ.get("AUDITLOG_LOGENTRY_MODEL", None):
|
||||
MIDDLEWARE = MIDDLEWARE + ["auditlog.middleware.AuditlogMiddleware"]
|
||||
else:
|
||||
MIDDLEWARE = MIDDLEWARE + ["middleware.CustomAuditlogMiddleware"]
|
||||
|
||||
|
||||
if TEST_DB_BACKEND == "postgresql":
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.postgresql",
|
||||
"NAME": os.getenv(
|
||||
"TEST_DB_NAME", "auditlog" + os.environ.get("TOX_PARALLEL_ENV", "")
|
||||
),
|
||||
"USER": os.getenv("TEST_DB_USER", "postgres"),
|
||||
"PASSWORD": os.getenv("TEST_DB_PASS", ""),
|
||||
"HOST": os.getenv("TEST_DB_HOST", "127.0.0.1"),
|
||||
"PORT": os.getenv("TEST_DB_PORT", "5432"),
|
||||
}
|
||||
}
|
||||
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 = [
|
||||
{
|
||||
"APP_DIRS": True,
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
"DIRS": [],
|
||||
"OPTIONS": {
|
||||
"context_processors": [
|
||||
"django.template.context_processors.request",
|
||||
"django.contrib.auth.context_processors.auth",
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
]
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
STATIC_URL = "/static/"
|
||||
|
||||
ROOT_URLCONF = "test_app.urls"
|
||||
|
||||
USE_TZ = True
|
||||
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
|
||||
|
||||
AUDITLOG_LOGENTRY_MODEL = os.environ.get("AUDITLOG_LOGENTRY_MODEL", "auditlog.LogEntry")
|
||||
198
auditlog_tests/test_two_step_json_migration.py
Normal file
198
auditlog_tests/test_two_step_json_migration.py
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
import json
|
||||
from io import StringIO
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.management import CommandError, call_command
|
||||
from django.test import TestCase, override_settings
|
||||
from django.test.utils import skipIf
|
||||
from test_app.models import SimpleModel
|
||||
|
||||
from auditlog import get_logentry_model
|
||||
|
||||
LogEntry = get_logentry_model()
|
||||
|
||||
|
||||
class TwoStepMigrationTest(TestCase):
|
||||
def test_use_text_changes_first(self):
|
||||
text_obj = '{"field": "changes_text"}'
|
||||
json_obj = {"field": "changes"}
|
||||
_params = [
|
||||
(True, None, text_obj, {"field": "changes_text"}),
|
||||
(True, json_obj, text_obj, json_obj),
|
||||
(True, None, "not json", {}),
|
||||
(False, json_obj, text_obj, json_obj),
|
||||
]
|
||||
|
||||
for setting_value, changes_value, changes_text_value, expected in _params:
|
||||
with self.subTest():
|
||||
entry = LogEntry(changes=changes_value, changes_text=changes_text_value)
|
||||
with self.settings(
|
||||
AUDITLOG_USE_TEXT_CHANGES_IF_JSON_IS_NOT_PRESENT=setting_value
|
||||
):
|
||||
from auditlog import models
|
||||
|
||||
changes_dict = models._changes_func()(entry)
|
||||
self.assertEqual(changes_dict, expected)
|
||||
|
||||
|
||||
class AuditlogMigrateJsonTest(TestCase):
|
||||
def make_logentry(self):
|
||||
model = SimpleModel.objects.create(text="I am a simple model.")
|
||||
log_entry: LogEntry = model.history.first()
|
||||
log_entry.changes_text = json.dumps(log_entry.changes)
|
||||
log_entry.changes = None
|
||||
log_entry.save()
|
||||
return log_entry
|
||||
|
||||
def call_command(self, *args, **kwargs):
|
||||
outbuf = StringIO()
|
||||
errbuf = StringIO()
|
||||
args = ("--no-color",) + args
|
||||
call_command(
|
||||
"auditlogmigratejson", *args, stdout=outbuf, stderr=errbuf, **kwargs
|
||||
)
|
||||
return outbuf.getvalue().strip(), errbuf.getvalue().strip()
|
||||
|
||||
def test_nothing_to_migrate(self):
|
||||
outbuf, errbuf = self.call_command()
|
||||
|
||||
msg = "All records have been migrated."
|
||||
self.assertEqual(outbuf, msg)
|
||||
|
||||
@override_settings(AUDITLOG_USE_TEXT_CHANGES_IF_JSON_IS_NOT_PRESENT=True)
|
||||
def test_nothing_to_migrate_with_conf_true(self):
|
||||
outbuf, errbuf = self.call_command()
|
||||
|
||||
msg = (
|
||||
"All records have been migrated.\n"
|
||||
"You can now set AUDITLOG_USE_TEXT_CHANGES_IF_JSON_IS_NOT_PRESENT to False."
|
||||
)
|
||||
|
||||
self.assertEqual(outbuf, msg)
|
||||
|
||||
def test_check(self):
|
||||
# Arrange
|
||||
log_entry = self.make_logentry()
|
||||
|
||||
# Act
|
||||
outbuf, errbuf = self.call_command("--check")
|
||||
log_entry.refresh_from_db()
|
||||
|
||||
# Assert
|
||||
self.assertEqual("There are 1 records that needs migration.", outbuf)
|
||||
self.assertEqual("", errbuf)
|
||||
self.assertIsNone(log_entry.changes)
|
||||
|
||||
def test_using_django(self):
|
||||
# Arrange
|
||||
log_entry = self.make_logentry()
|
||||
|
||||
# Act
|
||||
outbuf, errbuf = self.call_command("-b=0")
|
||||
log_entry.refresh_from_db()
|
||||
|
||||
# Assert
|
||||
self.assertEqual(errbuf, "")
|
||||
self.assertIsNotNone(log_entry.changes)
|
||||
|
||||
def test_using_django_batched(self):
|
||||
# Arrange
|
||||
log_entry_1 = self.make_logentry()
|
||||
log_entry_2 = self.make_logentry()
|
||||
|
||||
# Act
|
||||
outbuf, errbuf = self.call_command("-b=1")
|
||||
log_entry_1.refresh_from_db()
|
||||
log_entry_2.refresh_from_db()
|
||||
|
||||
# Assert
|
||||
self.assertEqual(errbuf, "")
|
||||
self.assertIsNotNone(log_entry_1.changes)
|
||||
self.assertIsNotNone(log_entry_2.changes)
|
||||
|
||||
def test_using_django_batched_call_count(self):
|
||||
"""
|
||||
This is split into a different test because I couldn't figure out how to properly patch bulk_update.
|
||||
For some reason, then I
|
||||
"""
|
||||
# Arrange
|
||||
self.make_logentry()
|
||||
self.make_logentry()
|
||||
|
||||
# Act
|
||||
LogEntry = get_logentry_model()
|
||||
path = f"{LogEntry.__module__}.{LogEntry.__name__}.objects.bulk_update"
|
||||
|
||||
with patch(path) as bulk_update:
|
||||
outbuf, errbuf = self.call_command("-b=1")
|
||||
call_count = bulk_update.call_count
|
||||
|
||||
# Assert
|
||||
self.assertEqual(call_count, 2)
|
||||
|
||||
@skipIf(settings.TEST_DB_BACKEND != "postgresql", "PostgreSQL-specific test")
|
||||
def test_native_postgres(self):
|
||||
# Arrange
|
||||
log_entry = self.make_logentry()
|
||||
|
||||
# Act
|
||||
outbuf, errbuf = self.call_command("-d=postgres")
|
||||
log_entry.refresh_from_db()
|
||||
|
||||
# Assert
|
||||
self.assertEqual(errbuf, "")
|
||||
self.assertIsNotNone(log_entry.changes)
|
||||
|
||||
@skipIf(settings.TEST_DB_BACKEND != "postgresql", "PostgreSQL-specific test")
|
||||
def test_native_postgres_changes_not_overwritten(self):
|
||||
# Arrange
|
||||
log_entry = self.make_logentry()
|
||||
log_entry.changes = original_changes = {"key": "value"}
|
||||
log_entry.changes_text = '{"key": "new value"}'
|
||||
log_entry.save()
|
||||
|
||||
# Act
|
||||
outbuf, errbuf = self.call_command("-d=postgres")
|
||||
log_entry.refresh_from_db()
|
||||
|
||||
# Assert
|
||||
self.assertEqual(errbuf, "")
|
||||
self.assertEqual(log_entry.changes, original_changes)
|
||||
|
||||
def test_native_unsupported(self):
|
||||
# Arrange
|
||||
log_entry = self.make_logentry()
|
||||
msg = (
|
||||
"Migrating the records using oracle is not implemented. "
|
||||
"Run this management command without passing a -d/--database argument."
|
||||
)
|
||||
|
||||
# Act
|
||||
with self.assertRaises(CommandError) as cm:
|
||||
self.call_command("-d=oracle")
|
||||
log_entry.refresh_from_db()
|
||||
|
||||
# Assert
|
||||
self.assertEqual(msg, cm.exception.args[0])
|
||||
self.assertIsNone(log_entry.changes)
|
||||
|
||||
def test_using_django_with_error(self):
|
||||
# Arrange
|
||||
log_entry = self.make_logentry()
|
||||
log_entry.changes_text = "not json"
|
||||
log_entry.save()
|
||||
|
||||
# Act
|
||||
outbuf, errbuf = self.call_command()
|
||||
log_entry.refresh_from_db()
|
||||
|
||||
# Assert
|
||||
msg = (
|
||||
f"ValueError was raised while converting the logs with these ids into json."
|
||||
f"They where not be included in this migration batch."
|
||||
f"\n"
|
||||
f"{[log_entry.id]}"
|
||||
)
|
||||
self.assertEqual(msg, errbuf)
|
||||
self.assertIsNone(log_entry.changes)
|
||||
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])
|
||||
3305
auditlog_tests/tests.py
Normal file
3305
auditlog_tests/tests.py
Normal file
File diff suppressed because it is too large
Load diff
185
docs/Makefile
185
docs/Makefile
|
|
@ -1,177 +1,20 @@
|
|||
# Makefile for Sphinx documentation
|
||||
# Minimal makefile for Sphinx documentation
|
||||
#
|
||||
|
||||
# You can set these variables from the command line.
|
||||
SPHINXOPTS =
|
||||
SPHINXBUILD = sphinx-build
|
||||
PAPER =
|
||||
BUILDDIR = build
|
||||
|
||||
# User-friendly check for sphinx-build
|
||||
ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1)
|
||||
$(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/)
|
||||
endif
|
||||
|
||||
# Internal variables.
|
||||
PAPEROPT_a4 = -D latex_paper_size=a4
|
||||
PAPEROPT_letter = -D latex_paper_size=letter
|
||||
ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source
|
||||
# the i18n builder cannot share the environment and doctrees with the others
|
||||
I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source
|
||||
|
||||
.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext
|
||||
# You can set these variables from the command line, and also
|
||||
# from the environment for the first two.
|
||||
SPHINXOPTS ?=
|
||||
SPHINXBUILD ?= sphinx-build
|
||||
SOURCEDIR = source
|
||||
BUILDDIR = _build
|
||||
|
||||
# Put it first so that "make" without argument is like "make help".
|
||||
help:
|
||||
@echo "Please use \`make <target>' where <target> is one of"
|
||||
@echo " html to make standalone HTML files"
|
||||
@echo " dirhtml to make HTML files named index.html in directories"
|
||||
@echo " singlehtml to make a single large HTML file"
|
||||
@echo " pickle to make pickle files"
|
||||
@echo " json to make JSON files"
|
||||
@echo " htmlhelp to make HTML files and a HTML help project"
|
||||
@echo " qthelp to make HTML files and a qthelp project"
|
||||
@echo " devhelp to make HTML files and a Devhelp project"
|
||||
@echo " epub to make an epub"
|
||||
@echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter"
|
||||
@echo " latexpdf to make LaTeX files and run them through pdflatex"
|
||||
@echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx"
|
||||
@echo " text to make text files"
|
||||
@echo " man to make manual pages"
|
||||
@echo " texinfo to make Texinfo files"
|
||||
@echo " info to make Texinfo files and run them through makeinfo"
|
||||
@echo " gettext to make PO message catalogs"
|
||||
@echo " changes to make an overview of all changed/added/deprecated items"
|
||||
@echo " xml to make Docutils-native XML files"
|
||||
@echo " pseudoxml to make pseudoxml-XML files for display purposes"
|
||||
@echo " linkcheck to check all external links for integrity"
|
||||
@echo " doctest to run all doctests embedded in the documentation (if enabled)"
|
||||
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
|
||||
|
||||
clean:
|
||||
rm -rf $(BUILDDIR)/*
|
||||
.PHONY: help Makefile
|
||||
|
||||
html:
|
||||
$(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html
|
||||
@echo
|
||||
@echo "Build finished. The HTML pages are in $(BUILDDIR)/html."
|
||||
|
||||
dirhtml:
|
||||
$(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml
|
||||
@echo
|
||||
@echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml."
|
||||
|
||||
singlehtml:
|
||||
$(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml
|
||||
@echo
|
||||
@echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml."
|
||||
|
||||
pickle:
|
||||
$(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle
|
||||
@echo
|
||||
@echo "Build finished; now you can process the pickle files."
|
||||
|
||||
json:
|
||||
$(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json
|
||||
@echo
|
||||
@echo "Build finished; now you can process the JSON files."
|
||||
|
||||
htmlhelp:
|
||||
$(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp
|
||||
@echo
|
||||
@echo "Build finished; now you can run HTML Help Workshop with the" \
|
||||
".hhp project file in $(BUILDDIR)/htmlhelp."
|
||||
|
||||
qthelp:
|
||||
$(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp
|
||||
@echo
|
||||
@echo "Build finished; now you can run "qcollectiongenerator" with the" \
|
||||
".qhcp project file in $(BUILDDIR)/qthelp, like this:"
|
||||
@echo "# qcollectiongenerator $(BUILDDIR)/qthelp/django-auditlog.qhcp"
|
||||
@echo "To view the help file:"
|
||||
@echo "# assistant -collectionFile $(BUILDDIR)/qthelp/django-auditlog.qhc"
|
||||
|
||||
devhelp:
|
||||
$(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp
|
||||
@echo
|
||||
@echo "Build finished."
|
||||
@echo "To view the help file:"
|
||||
@echo "# mkdir -p $$HOME/.local/share/devhelp/django-auditlog"
|
||||
@echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/django-auditlog"
|
||||
@echo "# devhelp"
|
||||
|
||||
epub:
|
||||
$(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub
|
||||
@echo
|
||||
@echo "Build finished. The epub file is in $(BUILDDIR)/epub."
|
||||
|
||||
latex:
|
||||
$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
|
||||
@echo
|
||||
@echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex."
|
||||
@echo "Run \`make' in that directory to run these through (pdf)latex" \
|
||||
"(use \`make latexpdf' here to do that automatically)."
|
||||
|
||||
latexpdf:
|
||||
$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
|
||||
@echo "Running LaTeX files through pdflatex..."
|
||||
$(MAKE) -C $(BUILDDIR)/latex all-pdf
|
||||
@echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
|
||||
|
||||
latexpdfja:
|
||||
$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
|
||||
@echo "Running LaTeX files through platex and dvipdfmx..."
|
||||
$(MAKE) -C $(BUILDDIR)/latex all-pdf-ja
|
||||
@echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
|
||||
|
||||
text:
|
||||
$(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text
|
||||
@echo
|
||||
@echo "Build finished. The text files are in $(BUILDDIR)/text."
|
||||
|
||||
man:
|
||||
$(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man
|
||||
@echo
|
||||
@echo "Build finished. The manual pages are in $(BUILDDIR)/man."
|
||||
|
||||
texinfo:
|
||||
$(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
|
||||
@echo
|
||||
@echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo."
|
||||
@echo "Run \`make' in that directory to run these through makeinfo" \
|
||||
"(use \`make info' here to do that automatically)."
|
||||
|
||||
info:
|
||||
$(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
|
||||
@echo "Running Texinfo files through makeinfo..."
|
||||
make -C $(BUILDDIR)/texinfo info
|
||||
@echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo."
|
||||
|
||||
gettext:
|
||||
$(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale
|
||||
@echo
|
||||
@echo "Build finished. The message catalogs are in $(BUILDDIR)/locale."
|
||||
|
||||
changes:
|
||||
$(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes
|
||||
@echo
|
||||
@echo "The overview file is in $(BUILDDIR)/changes."
|
||||
|
||||
linkcheck:
|
||||
$(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck
|
||||
@echo
|
||||
@echo "Link check complete; look for any errors in the above output " \
|
||||
"or in $(BUILDDIR)/linkcheck/output.txt."
|
||||
|
||||
doctest:
|
||||
$(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest
|
||||
@echo "Testing of doctests in the sources finished, look at the " \
|
||||
"results in $(BUILDDIR)/doctest/output.txt."
|
||||
|
||||
xml:
|
||||
$(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml
|
||||
@echo
|
||||
@echo "Build finished. The XML files are in $(BUILDDIR)/xml."
|
||||
|
||||
pseudoxml:
|
||||
$(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml
|
||||
@echo
|
||||
@echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml."
|
||||
# Catch-all target: route all unknown targets to Sphinx using the new
|
||||
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
|
||||
%: Makefile
|
||||
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
|
||||
|
|
|
|||
277
docs/make.bat
277
docs/make.bat
|
|
@ -1,242 +1,35 @@
|
|||
@ECHO OFF
|
||||
|
||||
REM Command file for Sphinx documentation
|
||||
|
||||
if "%SPHINXBUILD%" == "" (
|
||||
set SPHINXBUILD=sphinx-build
|
||||
)
|
||||
set BUILDDIR=build
|
||||
set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% source
|
||||
set I18NSPHINXOPTS=%SPHINXOPTS% source
|
||||
if NOT "%PAPER%" == "" (
|
||||
set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS%
|
||||
set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS%
|
||||
)
|
||||
|
||||
if "%1" == "" goto help
|
||||
|
||||
if "%1" == "help" (
|
||||
:help
|
||||
echo.Please use `make ^<target^>` where ^<target^> is one of
|
||||
echo. html to make standalone HTML files
|
||||
echo. dirhtml to make HTML files named index.html in directories
|
||||
echo. singlehtml to make a single large HTML file
|
||||
echo. pickle to make pickle files
|
||||
echo. json to make JSON files
|
||||
echo. htmlhelp to make HTML files and a HTML help project
|
||||
echo. qthelp to make HTML files and a qthelp project
|
||||
echo. devhelp to make HTML files and a Devhelp project
|
||||
echo. epub to make an epub
|
||||
echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter
|
||||
echo. text to make text files
|
||||
echo. man to make manual pages
|
||||
echo. texinfo to make Texinfo files
|
||||
echo. gettext to make PO message catalogs
|
||||
echo. changes to make an overview over all changed/added/deprecated items
|
||||
echo. xml to make Docutils-native XML files
|
||||
echo. pseudoxml to make pseudoxml-XML files for display purposes
|
||||
echo. linkcheck to check all external links for integrity
|
||||
echo. doctest to run all doctests embedded in the documentation if enabled
|
||||
goto end
|
||||
)
|
||||
|
||||
if "%1" == "clean" (
|
||||
for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i
|
||||
del /q /s %BUILDDIR%\*
|
||||
goto end
|
||||
)
|
||||
|
||||
|
||||
%SPHINXBUILD% 2> nul
|
||||
if errorlevel 9009 (
|
||||
echo.
|
||||
echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
|
||||
echo.installed, then set the SPHINXBUILD environment variable to point
|
||||
echo.to the full path of the 'sphinx-build' executable. Alternatively you
|
||||
echo.may add the Sphinx directory to PATH.
|
||||
echo.
|
||||
echo.If you don't have Sphinx installed, grab it from
|
||||
echo.http://sphinx-doc.org/
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
if "%1" == "html" (
|
||||
%SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html
|
||||
if errorlevel 1 exit /b 1
|
||||
echo.
|
||||
echo.Build finished. The HTML pages are in %BUILDDIR%/html.
|
||||
goto end
|
||||
)
|
||||
|
||||
if "%1" == "dirhtml" (
|
||||
%SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml
|
||||
if errorlevel 1 exit /b 1
|
||||
echo.
|
||||
echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml.
|
||||
goto end
|
||||
)
|
||||
|
||||
if "%1" == "singlehtml" (
|
||||
%SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml
|
||||
if errorlevel 1 exit /b 1
|
||||
echo.
|
||||
echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml.
|
||||
goto end
|
||||
)
|
||||
|
||||
if "%1" == "pickle" (
|
||||
%SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle
|
||||
if errorlevel 1 exit /b 1
|
||||
echo.
|
||||
echo.Build finished; now you can process the pickle files.
|
||||
goto end
|
||||
)
|
||||
|
||||
if "%1" == "json" (
|
||||
%SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json
|
||||
if errorlevel 1 exit /b 1
|
||||
echo.
|
||||
echo.Build finished; now you can process the JSON files.
|
||||
goto end
|
||||
)
|
||||
|
||||
if "%1" == "htmlhelp" (
|
||||
%SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp
|
||||
if errorlevel 1 exit /b 1
|
||||
echo.
|
||||
echo.Build finished; now you can run HTML Help Workshop with the ^
|
||||
.hhp project file in %BUILDDIR%/htmlhelp.
|
||||
goto end
|
||||
)
|
||||
|
||||
if "%1" == "qthelp" (
|
||||
%SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp
|
||||
if errorlevel 1 exit /b 1
|
||||
echo.
|
||||
echo.Build finished; now you can run "qcollectiongenerator" with the ^
|
||||
.qhcp project file in %BUILDDIR%/qthelp, like this:
|
||||
echo.^> qcollectiongenerator %BUILDDIR%\qthelp\django-auditlog.qhcp
|
||||
echo.To view the help file:
|
||||
echo.^> assistant -collectionFile %BUILDDIR%\qthelp\django-auditlog.ghc
|
||||
goto end
|
||||
)
|
||||
|
||||
if "%1" == "devhelp" (
|
||||
%SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp
|
||||
if errorlevel 1 exit /b 1
|
||||
echo.
|
||||
echo.Build finished.
|
||||
goto end
|
||||
)
|
||||
|
||||
if "%1" == "epub" (
|
||||
%SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub
|
||||
if errorlevel 1 exit /b 1
|
||||
echo.
|
||||
echo.Build finished. The epub file is in %BUILDDIR%/epub.
|
||||
goto end
|
||||
)
|
||||
|
||||
if "%1" == "latex" (
|
||||
%SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex
|
||||
if errorlevel 1 exit /b 1
|
||||
echo.
|
||||
echo.Build finished; the LaTeX files are in %BUILDDIR%/latex.
|
||||
goto end
|
||||
)
|
||||
|
||||
if "%1" == "latexpdf" (
|
||||
%SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex
|
||||
cd %BUILDDIR%/latex
|
||||
make all-pdf
|
||||
cd %BUILDDIR%/..
|
||||
echo.
|
||||
echo.Build finished; the PDF files are in %BUILDDIR%/latex.
|
||||
goto end
|
||||
)
|
||||
|
||||
if "%1" == "latexpdfja" (
|
||||
%SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex
|
||||
cd %BUILDDIR%/latex
|
||||
make all-pdf-ja
|
||||
cd %BUILDDIR%/..
|
||||
echo.
|
||||
echo.Build finished; the PDF files are in %BUILDDIR%/latex.
|
||||
goto end
|
||||
)
|
||||
|
||||
if "%1" == "text" (
|
||||
%SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text
|
||||
if errorlevel 1 exit /b 1
|
||||
echo.
|
||||
echo.Build finished. The text files are in %BUILDDIR%/text.
|
||||
goto end
|
||||
)
|
||||
|
||||
if "%1" == "man" (
|
||||
%SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man
|
||||
if errorlevel 1 exit /b 1
|
||||
echo.
|
||||
echo.Build finished. The manual pages are in %BUILDDIR%/man.
|
||||
goto end
|
||||
)
|
||||
|
||||
if "%1" == "texinfo" (
|
||||
%SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo
|
||||
if errorlevel 1 exit /b 1
|
||||
echo.
|
||||
echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo.
|
||||
goto end
|
||||
)
|
||||
|
||||
if "%1" == "gettext" (
|
||||
%SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale
|
||||
if errorlevel 1 exit /b 1
|
||||
echo.
|
||||
echo.Build finished. The message catalogs are in %BUILDDIR%/locale.
|
||||
goto end
|
||||
)
|
||||
|
||||
if "%1" == "changes" (
|
||||
%SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes
|
||||
if errorlevel 1 exit /b 1
|
||||
echo.
|
||||
echo.The overview file is in %BUILDDIR%/changes.
|
||||
goto end
|
||||
)
|
||||
|
||||
if "%1" == "linkcheck" (
|
||||
%SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck
|
||||
if errorlevel 1 exit /b 1
|
||||
echo.
|
||||
echo.Link check complete; look for any errors in the above output ^
|
||||
or in %BUILDDIR%/linkcheck/output.txt.
|
||||
goto end
|
||||
)
|
||||
|
||||
if "%1" == "doctest" (
|
||||
%SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest
|
||||
if errorlevel 1 exit /b 1
|
||||
echo.
|
||||
echo.Testing of doctests in the sources finished, look at the ^
|
||||
results in %BUILDDIR%/doctest/output.txt.
|
||||
goto end
|
||||
)
|
||||
|
||||
if "%1" == "xml" (
|
||||
%SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml
|
||||
if errorlevel 1 exit /b 1
|
||||
echo.
|
||||
echo.Build finished. The XML files are in %BUILDDIR%/xml.
|
||||
goto end
|
||||
)
|
||||
|
||||
if "%1" == "pseudoxml" (
|
||||
%SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml
|
||||
if errorlevel 1 exit /b 1
|
||||
echo.
|
||||
echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml.
|
||||
goto end
|
||||
)
|
||||
|
||||
:end
|
||||
@ECHO OFF
|
||||
|
||||
pushd %~dp0
|
||||
|
||||
REM Command file for Sphinx documentation
|
||||
|
||||
if "%SPHINXBUILD%" == "" (
|
||||
set SPHINXBUILD=sphinx-build
|
||||
)
|
||||
set SOURCEDIR=source
|
||||
set BUILDDIR=_build
|
||||
|
||||
if "%1" == "" goto help
|
||||
|
||||
%SPHINXBUILD% >NUL 2>NUL
|
||||
if errorlevel 9009 (
|
||||
echo.
|
||||
echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
|
||||
echo.installed, then set the SPHINXBUILD environment variable to point
|
||||
echo.to the full path of the 'sphinx-build' executable. Alternatively you
|
||||
echo.may add the Sphinx directory to PATH.
|
||||
echo.
|
||||
echo.If you don't have Sphinx installed, grab it from
|
||||
echo.https://sphinx-doc.org/
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
|
||||
goto end
|
||||
|
||||
:help
|
||||
%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
|
||||
|
||||
:end
|
||||
popd
|
||||
|
|
|
|||
6
docs/requirements.txt
Normal file
6
docs/requirements.txt
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
# Docs requirements
|
||||
django>=4.2,<4.3
|
||||
sphinx
|
||||
sphinx_rtd_theme
|
||||
psycopg2-binary
|
||||
mysqlclient==2.2.5
|
||||
|
|
@ -1,267 +1,72 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Configuration file for the Sphinx documentation builder.
|
||||
#
|
||||
# django-auditlog documentation build configuration file, created by
|
||||
# sphinx-quickstart on Wed Nov 6 20:39:48 2013.
|
||||
#
|
||||
# This file is execfile()d with the current directory set to its
|
||||
# containing dir.
|
||||
#
|
||||
# Note that not all possible configuration values are present in this
|
||||
# autogenerated file.
|
||||
#
|
||||
# All configuration values have a default; values that are commented out
|
||||
# serve to show the default.
|
||||
# This file only contains a selection of the most common options. For a full
|
||||
# list see the documentation:
|
||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html
|
||||
|
||||
# -- Preliminary -------------------------------------------------------------
|
||||
|
||||
import sys
|
||||
import os
|
||||
import sphinx_rtd_theme
|
||||
import sys
|
||||
from datetime import date
|
||||
|
||||
from auditlog import __version__
|
||||
|
||||
# -- Path setup --------------------------------------------------------------
|
||||
|
||||
# If extensions (or modules to document with autodoc) are in another directory,
|
||||
# add these directories to sys.path here. If the directory is relative to the
|
||||
# documentation root, use os.path.abspath to make it absolute, like shown here.
|
||||
sys.path.insert(0, os.path.abspath('../../src'))
|
||||
|
||||
# Django settings
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'testproject.settings')
|
||||
from django.conf import settings
|
||||
settings.configure()
|
||||
# Add sources folder
|
||||
|
||||
# -- General configuration ------------------------------------------------
|
||||
sys.path.insert(0, os.path.abspath("../../auditlog_tests"))
|
||||
|
||||
# If your documentation needs a minimal Sphinx version, state it here.
|
||||
#needs_sphinx = '1.0'
|
||||
# Setup Django for autodoc
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_settings")
|
||||
import django # noqa: E402
|
||||
|
||||
django.setup()
|
||||
|
||||
# -- Project information -----------------------------------------------------
|
||||
|
||||
project = "django-auditlog"
|
||||
author = "Jan-Jelle Kester and contributors"
|
||||
copyright = f"2013-{date.today().year}, {author}"
|
||||
|
||||
release = __version__
|
||||
# for example take major/minor
|
||||
version = ".".join(release.split(".")[:2])
|
||||
|
||||
# -- General configuration ---------------------------------------------------
|
||||
|
||||
# Add any Sphinx extension module names here, as strings. They can be
|
||||
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
|
||||
# ones.
|
||||
extensions = [
|
||||
'sphinx.ext.autodoc',
|
||||
'sphinx.ext.viewcode',
|
||||
"sphinx.ext.autodoc",
|
||||
"sphinx.ext.viewcode",
|
||||
]
|
||||
|
||||
# Master document that contains the root table of contents
|
||||
master_doc = "index"
|
||||
|
||||
# Add any paths that contain templates here, relative to this directory.
|
||||
templates_path = ['_templates']
|
||||
|
||||
# The suffix of source filenames.
|
||||
source_suffix = '.rst'
|
||||
|
||||
# The encoding of source files.
|
||||
#source_encoding = 'utf-8-sig'
|
||||
|
||||
# The master toctree document.
|
||||
master_doc = 'index'
|
||||
|
||||
# General information about the project.
|
||||
project = u'django-auditlog'
|
||||
copyright = u'2017, Jan-Jelle Kester and contributors'
|
||||
|
||||
# The version info for the project you're documenting, acts as replacement for
|
||||
# |version| and |release|, also used in various other places throughout the
|
||||
# built documents.
|
||||
#
|
||||
# The short X.Y version.
|
||||
version = '0.4'
|
||||
# The full version, including alpha/beta/rc tags.
|
||||
release = '0.4.7'
|
||||
|
||||
# The language for content autogenerated by Sphinx. Refer to documentation
|
||||
# for a list of supported languages.
|
||||
#language = None
|
||||
|
||||
# There are two options for replacing |today|: either, you set today to some
|
||||
# non-false value, then it is used:
|
||||
#today = ''
|
||||
# Else, today_fmt is used as the format for a strftime call.
|
||||
#today_fmt = '%B %d, %Y'
|
||||
templates_path = ["_templates"]
|
||||
|
||||
# List of patterns, relative to source directory, that match files and
|
||||
# directories to ignore when looking for source files.
|
||||
exclude_patterns = []
|
||||
# This pattern also affects html_static_path and html_extra_path.
|
||||
exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
|
||||
|
||||
# The reST default role (used for this markup: `text`) to use for all
|
||||
# documents.
|
||||
#default_role = None
|
||||
|
||||
# If true, '()' will be appended to :func: etc. cross-reference text.
|
||||
#add_function_parentheses = True
|
||||
|
||||
# If true, the current module name will be prepended to all description
|
||||
# unit titles (such as .. function::).
|
||||
#add_module_names = True
|
||||
|
||||
# If true, sectionauthor and moduleauthor directives will be shown in the
|
||||
# output. They are ignored by default.
|
||||
#show_authors = False
|
||||
|
||||
# The name of the Pygments (syntax highlighting) style to use.
|
||||
pygments_style = 'sphinx'
|
||||
|
||||
# A list of ignored prefixes for module index sorting.
|
||||
#modindex_common_prefix = []
|
||||
|
||||
# If true, keep warnings as "system message" paragraphs in the built documents.
|
||||
#keep_warnings = False
|
||||
|
||||
|
||||
# -- Options for HTML output ----------------------------------------------
|
||||
# -- Options for HTML output -------------------------------------------------
|
||||
|
||||
# The theme to use for HTML and HTML Help pages. See the documentation for
|
||||
# a list of builtin themes.
|
||||
html_theme = 'sphinx_rtd_theme'
|
||||
|
||||
# Theme options are theme-specific and customize the look and feel of a theme
|
||||
# further. For a list of options available for each theme, see the
|
||||
# documentation.
|
||||
#html_theme_options = {}
|
||||
|
||||
# Add any paths that contain custom themes here, relative to this directory.
|
||||
html_theme_path = [sphinx_rtd_theme.get_html_theme_path()]
|
||||
|
||||
# The name for this set of Sphinx documents. If None, it defaults to
|
||||
# "<project> v<release> documentation".
|
||||
#html_title = None
|
||||
|
||||
# A shorter title for the navigation bar. Default is the same as html_title.
|
||||
#html_short_title = None
|
||||
|
||||
# The name of an image file (relative to this directory) to place at the top
|
||||
# of the sidebar.
|
||||
#html_logo = None
|
||||
|
||||
# The name of an image file (within the static path) to use as favicon of the
|
||||
# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32
|
||||
# pixels large.
|
||||
#html_favicon = None
|
||||
#
|
||||
html_theme = "sphinx_rtd_theme"
|
||||
|
||||
# Add any paths that contain custom static files (such as style sheets) here,
|
||||
# relative to this directory. They are copied after the builtin static files,
|
||||
# so a file named "default.css" will overwrite the builtin "default.css".
|
||||
html_static_path = ['_static']
|
||||
|
||||
# Add any extra paths that contain custom files (such as robots.txt or
|
||||
# .htaccess) here, relative to this directory. These files are copied
|
||||
# directly to the root of the documentation.
|
||||
#html_extra_path = []
|
||||
|
||||
# If not '', a 'Last updated on:' timestamp is inserted at every page bottom,
|
||||
# using the given strftime format.
|
||||
#html_last_updated_fmt = '%b %d, %Y'
|
||||
|
||||
# If true, SmartyPants will be used to convert quotes and dashes to
|
||||
# typographically correct entities.
|
||||
#html_use_smartypants = True
|
||||
|
||||
# Custom sidebar templates, maps document names to template names.
|
||||
#html_sidebars = {}
|
||||
|
||||
# Additional templates that should be rendered to pages, maps page names to
|
||||
# template names.
|
||||
#html_additional_pages = {}
|
||||
|
||||
# If false, no module index is generated.
|
||||
#html_domain_indices = True
|
||||
|
||||
# If false, no index is generated.
|
||||
#html_use_index = True
|
||||
|
||||
# If true, the index is split into individual pages for each letter.
|
||||
#html_split_index = False
|
||||
|
||||
# If true, links to the reST sources are added to the pages.
|
||||
#html_show_sourcelink = True
|
||||
|
||||
# If true, "Created using Sphinx" is shown in the HTML footer. Default is True.
|
||||
#html_show_sphinx = True
|
||||
|
||||
# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True.
|
||||
#html_show_copyright = True
|
||||
|
||||
# If true, an OpenSearch description file will be output, and all pages will
|
||||
# contain a <link> tag referring to it. The value of this option must be the
|
||||
# base URL from which the finished HTML is served.
|
||||
#html_use_opensearch = ''
|
||||
|
||||
# This is the file name suffix for HTML files (e.g. ".xhtml").
|
||||
#html_file_suffix = None
|
||||
|
||||
# Output file base name for HTML help builder.
|
||||
htmlhelp_basename = 'django-auditlogdoc'
|
||||
|
||||
|
||||
# -- Options for LaTeX output ---------------------------------------------
|
||||
|
||||
latex_elements = {
|
||||
# The paper size ('letterpaper' or 'a4paper').
|
||||
#'papersize': 'letterpaper',
|
||||
|
||||
# The font size ('10pt', '11pt' or '12pt').
|
||||
#'pointsize': '10pt',
|
||||
|
||||
# Additional stuff for the LaTeX preamble.
|
||||
#'preamble': '',
|
||||
}
|
||||
|
||||
# Grouping the document tree into LaTeX files. List of tuples
|
||||
# (source start file, target name, title,
|
||||
# author, documentclass [howto, manual, or own class]).
|
||||
latex_documents = [
|
||||
('index', 'django-auditlog.tex', u'django-auditlog Documentation',
|
||||
u'Jan-Jelle Kester', 'manual'),
|
||||
]
|
||||
|
||||
# The name of an image file (relative to this directory) to place at the top of
|
||||
# the title page.
|
||||
#latex_logo = None
|
||||
|
||||
# For "manual" documents, if this is true, then toplevel headings are parts,
|
||||
# not chapters.
|
||||
#latex_use_parts = False
|
||||
|
||||
# If true, show page references after internal links.
|
||||
#latex_show_pagerefs = False
|
||||
|
||||
# If true, show URL addresses after external links.
|
||||
#latex_show_urls = False
|
||||
|
||||
# Documents to append as an appendix to all manuals.
|
||||
#latex_appendices = []
|
||||
|
||||
# If false, no module index is generated.
|
||||
#latex_domain_indices = True
|
||||
|
||||
|
||||
# -- Options for manual page output ---------------------------------------
|
||||
|
||||
# One entry per manual page. List of tuples
|
||||
# (source start file, name, description, authors, manual section).
|
||||
man_pages = [
|
||||
('index', 'django-auditlog', u'django-auditlog Documentation',
|
||||
[u'Jan-Jelle Kester'], 1)
|
||||
]
|
||||
|
||||
# If true, show URL addresses after external links.
|
||||
#man_show_urls = False
|
||||
|
||||
|
||||
# -- Options for Texinfo output -------------------------------------------
|
||||
|
||||
# Grouping the document tree into Texinfo files. List of tuples
|
||||
# (source start file, target name, title, author,
|
||||
# dir menu entry, description, category)
|
||||
texinfo_documents = [
|
||||
('index', 'django-auditlog', u'django-auditlog Documentation',
|
||||
u'Jan-Jelle Kester', 'django-auditlog', '',
|
||||
'Miscellaneous'),
|
||||
]
|
||||
|
||||
# Documents to append as an appendix to all manuals.
|
||||
#texinfo_appendices = []
|
||||
|
||||
# If false, no module index is generated.
|
||||
#texinfo_domain_indices = True
|
||||
|
||||
# How to display URL addresses: 'footnote', 'no', or 'inline'.
|
||||
#texinfo_show_urls = 'footnote'
|
||||
|
||||
# If true, do not generate a @detailmenu in the "Top" node's menu.
|
||||
#texinfo_no_detailmenu = False
|
||||
# html_static_path = ['_static']
|
||||
|
|
|
|||
|
|
@ -21,20 +21,15 @@ Contents
|
|||
|
||||
installation
|
||||
usage
|
||||
upgrade
|
||||
internals
|
||||
|
||||
|
||||
Contribute to Auditlog
|
||||
----------------------
|
||||
|
||||
.. note::
|
||||
Due to multiple reasons the development of Auditlog is not a priority for me at this moment. Therefore progress might
|
||||
be slow. This does not mean that this project is abandoned! Community involvement in the form of pull requests is
|
||||
very much appreciated. Also, if you like to take Auditlog to the next level and be a permanent contributor, please
|
||||
contact the author. Contact information can be found via GitHub.
|
||||
|
||||
If you discovered a bug or want to improve the code, please submit an issue and/or pull request via GitHub.
|
||||
Before submitting a new issue, please make sure there is no issue submitted that involves the same problem.
|
||||
|
||||
| GitHub repository: https://github.com/jjkester/django-auditlog
|
||||
| Issues: https://github.com/jjkester/django-auditlog/issues
|
||||
| GitHub repository: https://github.com/jazzband/django-auditlog
|
||||
| Issues: https://github.com/jazzband/django-auditlog/issues
|
||||
|
|
|
|||
|
|
@ -7,15 +7,15 @@ way to do this is by using the Python Package Index (PyPI). Simply run the follo
|
|||
``pip install django-auditlog``
|
||||
|
||||
Instead of installing Auditlog via PyPI, you can also clone the Git repository or download the source code via GitHub.
|
||||
The repository can be found at https://github.com/jjkester/django-auditlog/.
|
||||
The repository can be found at https://github.com/jazzband/django-auditlog/.
|
||||
|
||||
**Requirements**
|
||||
|
||||
- Python 2.7, 3.4 or higher
|
||||
- Django 1.8 or higher
|
||||
- Python 3.10 or higher
|
||||
- Django 4.2, 5.0, 5.1, and 5.2
|
||||
|
||||
Auditlog is currently tested with Python 2.7 and 3.4 and Django 1.8, 1.9 and 1.10. The latest test report can be found
|
||||
at https://travis-ci.org/jjkester/django-auditlog.
|
||||
Auditlog is currently tested with Python 3.10+ and Django 4.2, 5.0, 5.1, and 5.2. The latest test report can be found
|
||||
at https://github.com/jazzband/django-auditlog/actions.
|
||||
|
||||
Adding Auditlog to your Django application
|
||||
------------------------------------------
|
||||
|
|
@ -24,5 +24,5 @@ To use Auditlog in your application, just add ``'auditlog'`` to your project's `
|
|||
``manage.py migrate`` to create/upgrade the necessary database structure.
|
||||
|
||||
If you want Auditlog to automatically set the actor for log entries you also need to enable the middleware by adding
|
||||
``'auditlog.middleware.AuditlogMiddleware'`` to your ``MIDDLEWARE_CLASSES`` setting. Please check :doc:`usage` for more
|
||||
``'auditlog.middleware.AuditlogMiddleware'`` to your ``MIDDLEWARE`` setting. Please check :doc:`usage` for more
|
||||
information.
|
||||
|
|
|
|||
|
|
@ -19,12 +19,30 @@ Middleware
|
|||
.. automodule:: auditlog.middleware
|
||||
:members: AuditlogMiddleware
|
||||
|
||||
Correlation ID
|
||||
--------------
|
||||
|
||||
.. automodule:: auditlog.cid
|
||||
:members: get_cid, set_cid
|
||||
|
||||
Signal receivers
|
||||
----------------
|
||||
|
||||
.. automodule:: auditlog.receivers
|
||||
:members:
|
||||
|
||||
Custom Signals
|
||||
--------------
|
||||
Django Auditlog provides two custom signals that will hook in before
|
||||
and after any Auditlog record is written from a ``create``, ``update``,
|
||||
``delete``, or ``accessed`` action on an audited model.
|
||||
|
||||
.. automodule:: auditlog.signals
|
||||
:members:
|
||||
:member-order: bysource
|
||||
|
||||
.. versionadded:: 3.0.0
|
||||
|
||||
Calculating changes
|
||||
-------------------
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue