pax_global_header 0000666 0000000 0000000 00000000064 15003135774 0014517 g ustar 00root root 0000000 0000000 52 comment=3f7f0053778402cc19a98924e0e64a3d91ebd7e4
django-auditlog-3.1.2/ 0000775 0000000 0000000 00000000000 15003135774 0014572 5 ustar 00root root 0000000 0000000 django-auditlog-3.1.2/.github/ 0000775 0000000 0000000 00000000000 15003135774 0016132 5 ustar 00root root 0000000 0000000 django-auditlog-3.1.2/.github/dependabot.yml 0000664 0000000 0000000 00000001103 15003135774 0020755 0 ustar 00root root 0000000 0000000 # 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
django-auditlog-3.1.2/.github/workflows/ 0000775 0000000 0000000 00000000000 15003135774 0020167 5 ustar 00root root 0000000 0000000 django-auditlog-3.1.2/.github/workflows/release.yml 0000664 0000000 0000000 00000002540 15003135774 0022333 0 ustar 00root root 0000000 0000000 name: Release
on:
push:
tags:
- '*'
jobs:
build:
if: github.repository == 'jazzband/django-auditlog'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.9'
- name: Get pip cache dir
id: pip-cache
run: |
echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT
- name: Cache
uses: actions/cache@v4
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
django-auditlog-3.1.2/.github/workflows/test.yml 0000664 0000000 0000000 00000003224 15003135774 0021672 0 ustar 00root root 0000000 0000000 name: Test
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
strategy:
fail-fast: false
max-parallel: 5
matrix:
python-version: ['3.9', '3.10', '3.11', '3.12', '3.13']
services:
postgres:
image: postgres:14
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: postgres
ports:
- 5432/tcp
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Get pip cache dir
id: pip-cache
run: |
echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT
- name: Cache
uses: actions/cache@v4
with:
path: ${{ steps.pip-cache.outputs.dir }}
key:
-${{ matrix.python-version }}-v1-${{ hashFiles('**/setup.py') }}
restore-keys: |
-${{ matrix.python-version }}-v1-
- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install --upgrade tox tox-gh-actions
- name: Tox tests
run: |
tox -v
env:
TEST_DB_HOST: localhost
TEST_DB_USER: postgres
TEST_DB_PASS: postgres
TEST_DB_NAME: postgres
TEST_DB_PORT: ${{ job.services.postgres.ports[5432] }}
- name: Upload coverage
uses: codecov/codecov-action@v5
with:
name: Python ${{ matrix.python-version }}
django-auditlog-3.1.2/.gitignore 0000664 0000000 0000000 00000001600 15003135774 0016557 0 ustar 00root root 0000000 0000000 ### 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
*.mo
*.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/
django-auditlog-3.1.2/.pre-commit-config.yaml 0000664 0000000 0000000 00000001253 15003135774 0021054 0 ustar 00root root 0000000 0000000 ---
repos:
- repo: https://github.com/psf/black
rev: 25.1.0
hooks:
- id: black
language_version: python3.9
args:
- "--target-version"
- "py39"
- repo: https://github.com/PyCQA/flake8
rev: "7.2.0"
hooks:
- id: flake8
args: ["--max-line-length", "110"]
- repo: https://github.com/PyCQA/isort
rev: 6.0.1
hooks:
- id: isort
- repo: https://github.com/asottile/pyupgrade
rev: v3.19.1
hooks:
- id: pyupgrade
args: [--py39-plus]
- repo: https://github.com/adamchainz/django-upgrade
rev: 1.24.0
hooks:
- id: django-upgrade
args: [--target-version, "4.2"]
django-auditlog-3.1.2/.readthedocs.yaml 0000664 0000000 0000000 00000001327 15003135774 0020024 0 ustar 00root root 0000000 0000000 # 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: .
django-auditlog-3.1.2/CHANGELOG.md 0000664 0000000 0000000 00000041720 15003135774 0016407 0 ustar 00root root 0000000 0000000 # Changes
## Next Release
#### Improvements
#### Fixes
## 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.
django-auditlog-3.1.2/CODE_OF_CONDUCT.md 0000664 0000000 0000000 00000004507 15003135774 0017377 0 ustar 00root root 0000000 0000000 # 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/
django-auditlog-3.1.2/CONTRIBUTING.md 0000664 0000000 0000000 00000000464 15003135774 0017027 0 ustar 00root root 0000000 0000000 [](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).
django-auditlog-3.1.2/LICENSE 0000664 0000000 0000000 00000002100 15003135774 0015570 0 ustar 00root root 0000000 0000000 The MIT License (MIT)
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
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
django-auditlog-3.1.2/README.md 0000664 0000000 0000000 00000005440 15003135774 0016054 0 ustar 00root root 0000000 0000000 django-auditlog
===============
[](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)
**Migrate to V3**
Check the [Upgrading to version 3](https://django-auditlog.readthedocs.io/en/latest/upgrade.html) doc before upgrading to V3.
```django-auditlog``` (Auditlog) is a reusable app for Django that makes logging object changes a breeze. Auditlog tries to use as much as Python and Django's built in functionality to keep the list of dependencies as short as possible. Also, Auditlog aims to be fast and simple to use.
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.
Documentation
-------------
The documentation for ```django-auditlog``` can be found on https://django-auditlog.readthedocs.org. The source files are available in the ```docs``` folder.
License
-------
Auditlog is licensed under the MIT license (see the ```LICENSE``` file for details).
Contribute
----------
If you have great ideas for Auditlog, or if you like to improve something, feel free to fork this repository and/or create a pull request. I'm open for suggestions. If you like to discuss something with me (about Auditlog), please open an issue.
Releases
--------
1. Make sure all tests on `master` are green
2. Create a new branch `vX.Y.Z` from master for that specific release
3. Update the CHANGELOG release date
4. Pull request `vX.Y.Z` -> `master`
5. As a project lead, once the PR is merged, create and push a tag `vX.Y.Z`: this will trigger the release build and a notification will be sent from Jazzband of the availability of two packages (tgz and wheel)
6. Test the install
7. Publish the release to PyPI
django-auditlog-3.1.2/auditlog/ 0000775 0000000 0000000 00000000000 15003135774 0016402 5 ustar 00root root 0000000 0000000 django-auditlog-3.1.2/auditlog/__init__.py 0000664 0000000 0000000 00000000121 15003135774 0020505 0 ustar 00root root 0000000 0000000 from importlib.metadata import version
__version__ = version("django-auditlog")
django-auditlog-3.1.2/auditlog/admin.py 0000664 0000000 0000000 00000003513 15003135774 0020046 0 ustar 00root root 0000000 0000000 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.filters import CIDFilter, ResourceTypeFilter
from auditlog.mixins import LogEntryAdminMixin
from auditlog.models import LogEntry
@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)
django-auditlog-3.1.2/auditlog/apps.py 0000664 0000000 0000000 00000000665 15003135774 0017726 0 ustar 00root root 0000000 0000000 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()
django-auditlog-3.1.2/auditlog/cid.py 0000664 0000000 0000000 00000004147 15003135774 0017521 0 ustar 00root root 0000000 0000000 from contextvars import ContextVar
from typing import Optional
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: Optional[HttpRequest] = 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() -> Optional[str]:
return correlation_id.get()
def get_cid() -> Optional[str]:
"""
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)()
django-auditlog-3.1.2/auditlog/conf.py 0000664 0000000 0000000 00000003402 15003135774 0017700 0 ustar 00root root 0000000 0000000 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
)
django-auditlog-3.1.2/auditlog/context.py 0000664 0000000 0000000 00000004414 15003135774 0020443 0 ustar 00root root 0000000 0000000 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.models import LogEntry
auditlog_value = ContextVar("auditlog_value")
auditlog_disabled = ContextVar("auditlog_disabled", default=False)
@contextlib.contextmanager
def set_actor(actor, remote_addr=None, remote_port=None):
"""Connect a signal receiver with current user attached."""
# Initialize thread local storage
context_data = {
"signal_duid": ("set_actor", time.time()),
"remote_addr": remote_addr,
"remote_port": remote_port,
}
auditlog_value.set(context_data)
# Connect signal for automatic logging
set_actor = partial(
_set_actor,
user=actor,
signal_duid=context_data["signal_duid"],
)
pre_save.connect(
set_actor,
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(user, sender, instance, signal_duid, **kwargs):
"""Signal receiver with extra 'user' and 'signal_duid' kwargs.
This function becomes a valid signal receiver when it is curried with the actor and a dispatch id.
"""
try:
auditlog = auditlog_value.get()
except LookupError:
pass
else:
if signal_duid != auditlog["signal_duid"]:
return
auth_user_model = get_user_model()
if (
sender == LogEntry
and isinstance(user, auth_user_model)
and instance.actor is None
):
instance.actor = user
instance.actor_email = hasattr(user, "email") and user.email or None
instance.remote_addr = auditlog["remote_addr"]
instance.remote_port = auditlog["remote_port"]
@contextlib.contextmanager
def disable_auditlog():
token = auditlog_disabled.set(True)
try:
yield
finally:
try:
auditlog_disabled.reset(token)
except LookupError:
pass
django-auditlog-3.1.2/auditlog/diff.py 0000664 0000000 0000000 00000015361 15003135774 0017672 0 ustar 00root root 0000000 0000000 import json
from datetime import timezone
from typing import Optional
from django.conf import settings
from django.core.exceptions import 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
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
"""
from auditlog.models import LogEntry
# 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 == LogEntry
):
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):
"""
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
"""
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, None)
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, None))
try:
value = json.dumps(value, sort_keys=True, cls=field.encoder)
except TypeError:
pass
elif (field.one_to_one or field.many_to_one) and hasattr(field, "rel_class"):
value = smart_str(
getattr(obj, field.get_attname(), None), strings_only=True
)
else:
value = smart_str(getattr(obj, field.name, None))
if type(value).__name__ == "__proxy__":
value = str(value)
except ObjectDoesNotExist:
value = (
field.default
if getattr(field, "default", NOT_PROVIDED) is not NOT_PROVIDED
else None
)
return value
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: Optional[Model], new: Optional[Model], fields_to_check=None
):
"""
Calculates the differences between two model instances. One of the instances may be ``None``
(i.e., a newly created model or deleted model). This will cause all fields with a value to have
changed (from ``None``).
:param old: The old state of the model instance.
:type old: Model
:param new: The new state of the model instance.
:type new: Model
:param fields_to_check: An iterable of the field names to restrict the diff to, while ignoring the rest of
the model's fields. This is used to pass the `update_fields` kwarg from the model's `save` method.
: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)
new_value = get_field_value(new, field)
if old_value != new_value:
if model_fields and field.name in model_fields["mask_fields"]:
diff[field.name] = (
mask_str(smart_str(old_value)),
mask_str(smart_str(new_value)),
)
else:
diff[field.name] = (smart_str(old_value), smart_str(new_value))
if len(diff) == 0:
diff = None
return diff
django-auditlog-3.1.2/auditlog/filters.py 0000664 0000000 0000000 00000001726 15003135774 0020432 0 ustar 00root root 0000000 0000000 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())
django-auditlog-3.1.2/auditlog/management/ 0000775 0000000 0000000 00000000000 15003135774 0020516 5 ustar 00root root 0000000 0000000 django-auditlog-3.1.2/auditlog/management/__init__.py 0000664 0000000 0000000 00000000000 15003135774 0022615 0 ustar 00root root 0000000 0000000 django-auditlog-3.1.2/auditlog/management/commands/ 0000775 0000000 0000000 00000000000 15003135774 0022317 5 ustar 00root root 0000000 0000000 django-auditlog-3.1.2/auditlog/management/commands/__init__.py 0000664 0000000 0000000 00000000000 15003135774 0024416 0 ustar 00root root 0000000 0000000 django-auditlog-3.1.2/auditlog/management/commands/auditlogflush.py 0000664 0000000 0000000 00000006137 15003135774 0025552 0 ustar 00root root 0000000 0000000 import datetime
from django.core.management.base import BaseCommand
from django.db import connection
from auditlog.models import LogEntry
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", "sqlite", "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};"
django-auditlog-3.1.2/auditlog/management/commands/auditlogmigratejson.py 0000664 0000000 0000000 00000011304 15003135774 0026743 0 ustar 00root root 0000000 0000000 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.models import LogEntry
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(
"""
UPDATE auditlog_logentry
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."
)
django-auditlog-3.1.2/auditlog/middleware.py 0000664 0000000 0000000 00000004317 15003135774 0021076 0 ustar 00root root 0000000 0000000 from typing import Optional
from django.conf import settings
from django.contrib.auth import get_user_model
from auditlog.cid import set_cid
from auditlog.context import set_actor
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) -> Optional[int]:
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 __call__(self, request):
remote_addr = self._get_remote_addr(request)
remote_port = self._get_remote_port(request)
user = self._get_actor(request)
set_cid(request)
with set_actor(actor=user, remote_addr=remote_addr, remote_port=remote_port):
return self.get_response(request)
django-auditlog-3.1.2/auditlog/migrations/ 0000775 0000000 0000000 00000000000 15003135774 0020556 5 ustar 00root root 0000000 0000000 django-auditlog-3.1.2/auditlog/migrations/0001_initial.py 0000664 0000000 0000000 00000005221 15003135774 0023221 0 ustar 00root root 0000000 0000000 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,),
),
]
django-auditlog-3.1.2/auditlog/migrations/0002_auto_support_long_primary_keys.py 0000664 0000000 0000000 00000000640 15003135774 0030152 0 ustar 00root root 0000000 0000000 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
),
),
]
django-auditlog-3.1.2/auditlog/migrations/0003_logentry_remote_addr.py 0000664 0000000 0000000 00000000663 15003135774 0026007 0 ustar 00root root 0000000 0000000 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
),
),
]
django-auditlog-3.1.2/auditlog/migrations/0004_logentry_detailed_object_repr.py 0000664 0000000 0000000 00000000544 15003135774 0027652 0 ustar 00root root 0000000 0000000 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),
),
]
django-auditlog-3.1.2/auditlog/migrations/0005_logentry_additional_data_verbose_name.py 0000664 0000000 0000000 00000000655 15003135774 0031353 0 ustar 00root root 0000000 0000000 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
),
),
]
django-auditlog-3.1.2/auditlog/migrations/0006_object_pk_index.py 0000664 0000000 0000000 00000000661 15003135774 0024727 0 ustar 00root root 0000000 0000000 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
),
),
]
django-auditlog-3.1.2/auditlog/migrations/0007_object_pk_type.py 0000664 0000000 0000000 00000000633 15003135774 0024601 0 ustar 00root root 0000000 0000000 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
),
),
]
django-auditlog-3.1.2/auditlog/migrations/0008_action_index.py 0000664 0000000 0000000 00000000754 15003135774 0024251 0 ustar 00root root 0000000 0000000 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",
),
),
]
django-auditlog-3.1.2/auditlog/migrations/0009_alter_logentry_additional_data.py 0000664 0000000 0000000 00000000634 15003135774 0030016 0 ustar 00root root 0000000 0000000 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"
),
),
]
django-auditlog-3.1.2/auditlog/migrations/0010_alter_logentry_timestamp.py 0000664 0000000 0000000 00000000742 15003135774 0026710 0 ustar 00root root 0000000 0000000 # 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"
),
),
]
django-auditlog-3.1.2/auditlog/migrations/0011_logentry_serialized_data.py 0000664 0000000 0000000 00000000613 15003135774 0026640 0 ustar 00root root 0000000 0000000 # 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),
),
]
django-auditlog-3.1.2/auditlog/migrations/0012_add_logentry_action_access.py 0000664 0000000 0000000 00000001066 15003135774 0027126 0 ustar 00root root 0000000 0000000 # 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",
),
),
]
django-auditlog-3.1.2/auditlog/migrations/0013_alter_logentry_timestamp.py 0000664 0000000 0000000 00000001054 15003135774 0026710 0 ustar 00root root 0000000 0000000 # 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",
),
),
]
django-auditlog-3.1.2/auditlog/migrations/0014_logentry_cid.py 0000664 0000000 0000000 00000001052 15003135774 0024254 0 ustar 00root root 0000000 0000000 # 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",
),
),
]
django-auditlog-3.1.2/auditlog/migrations/0015_alter_logentry_changes.py 0000664 0000000 0000000 00000002165 15003135774 0026323 0 ustar 00root root 0000000 0000000 # 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()]
django-auditlog-3.1.2/auditlog/migrations/0016_logentry_remote_port.py 0000664 0000000 0000000 00000000647 15003135774 0026067 0 ustar 00root root 0000000 0000000 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"
),
),
]
django-auditlog-3.1.2/auditlog/migrations/0017_add_actor_email.py 0000664 0000000 0000000 00000000733 15003135774 0024671 0 ustar 00root root 0000000 0000000 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,
),
),
]
django-auditlog-3.1.2/auditlog/migrations/__init__.py 0000664 0000000 0000000 00000000000 15003135774 0022655 0 ustar 00root root 0000000 0000000 django-auditlog-3.1.2/auditlog/mixins.py 0000664 0000000 0000000 00000013724 15003135774 0020272 0 ustar 00root root 0000000 0000000 from django import urls as urlresolvers
from django.conf import settings
from django.contrib import admin
from django.core.exceptions import FieldDoesNotExist
from django.forms.utils import pretty_name
from django.http import HttpRequest
from django.urls.exceptions import NoReverseMatch
from django.utils.html import format_html, format_html_join
from django.utils.safestring import mark_safe
from django.utils.timezone import is_aware, localtime
from django.utils.translation import gettext_lazy as _
from auditlog.models import LogEntry
from auditlog.registry import auditlog
from auditlog.signals import accessed
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('{}', 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(
'{} - {}', 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):
changes = obj.changes_dict
atom_changes = {}
m2m_changes = {}
for field, change in changes.items():
if isinstance(change, dict):
assert (
change["type"] == "m2m"
), "Only m2m operations are expected to produce dict changes now"
m2m_changes[field] = change
else:
atom_changes[field] = change
msg = []
if atom_changes:
msg.append("
")
msg.append(self._format_header("#", "Field", "From", "To"))
for i, (field, change) in enumerate(sorted(atom_changes.items()), 1):
value = [i, self.field_verbose_name(obj, field)] + (
["***", "***"] if field == "password" else change
)
msg.append(self._format_line(*value))
msg.append("
")
if m2m_changes:
msg.append("")
msg.append(self._format_header("#", "Relationship", "Action", "Objects"))
for i, (field, change) in enumerate(sorted(m2m_changes.items()), 1):
change_html = format_html_join(
mark_safe("
"),
"{}",
[(value,) for value in change["objects"]],
)
msg.append(
format_html(
"{} | {} | {} | {} |
",
i,
self.field_verbose_name(obj, field),
change["operation"],
change_html,
)
)
msg.append("
")
return mark_safe("".join(msg))
@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(
'{}', url, self.CID_TITLE, cid
)
def _format_header(self, *labels):
return format_html(
"".join(["", "{} | " * len(labels), "
"]), *labels
)
def _format_line(self, *values):
return format_html(
"".join(["", "{} | " * len(values), "
"]), *values
)
def field_verbose_name(self, obj, field_name: str):
model = obj.content_type.model_class()
if model is None:
return field_name
try:
model_fields = auditlog.get_model_fields(model._meta.model)
mapping_field_name = model_fields["mapping_fields"].get(field_name)
if mapping_field_name:
return mapping_field_name
except KeyError:
# Model definition in auditlog was probably removed
pass
try:
field = model._meta.get_field(field_name)
return pretty_name(getattr(field, "verbose_name", field_name))
except FieldDoesNotExist:
return pretty_name(field_name)
def _add_query_parameter(self, key: str, value: str):
full_path = self.request.get_full_path()
delimiter = "&" if "?" in full_path else "?"
return f"{full_path}{delimiter}{key}={value}"
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)
django-auditlog-3.1.2/auditlog/models.py 0000664 0000000 0000000 00000056445 15003135774 0020255 0 ustar 00root root 0000000 0000000 import ast
import contextlib
import json
from copy import deepcopy
from datetime import timezone
from typing import Any, Callable, Union
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.diff import mask_str
DEFAULT_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)
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]
) -> dict[str, Any]:
all_field_data = data.pop("fields")
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_str(value)
else:
masked_field_data[key] = value
data["fields"] = masked_field_data
return data
class LogEntry(models.Model):
"""
Represents an entry in the audit log. The content type is saved along with the textual and numeric
(if available) primary key, as well as the textual representation of the object when it was saved.
It holds the action performed and the fields that were changed in the transaction.
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:
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.
:param separator: The string to place between each field.
:return: A readable string of the changes in this log entry.
"""
substrings = []
for field, values in self.changes_dict.items():
substring = "{field_name:s}{colon:s}{old:s}{arrow:s}{new:s}".format(
field_name=field,
colon=colon,
old=values[0],
arrow=arrow,
new=values[1],
)
substrings.append(substring)
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)
changes_display_dict = {}
# grab the changes_dict and iterate through
for field_name, values in self.changes_dict.items():
# try to get the field attribute on the model
try:
field = model._meta.get_field(field_name)
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: Union[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:
return smart_str(field.related_model._default_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 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"] = LogEntry
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
django-auditlog-3.1.2/auditlog/receivers.py 0000664 0000000 0000000 00000012473 15003135774 0020752 0 ustar 00root root 0000000 0000000 from functools import wraps
from django.conf import settings
from auditlog.context import auditlog_disabled
from auditlog.diff import model_instance_diff
from auditlog.models import LogEntry
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=LogEntry.Action.CREATE,
instance=instance,
sender=sender,
diff_old=None,
diff_new=instance,
)
@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 = sender._default_manager.filter(pk=instance.pk).first()
_create_log_entry(
action=LogEntry.Action.UPDATE,
instance=instance,
sender=sender,
diff_old=old,
diff_new=instance,
fields_to_check=update_fields,
)
@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=LogEntry.Action.DELETE,
instance=instance,
sender=sender,
diff_old=instance,
diff_new=None,
)
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=LogEntry.Action.ACCESS,
instance=instance,
sender=sender,
diff_old=None,
diff_new=None,
force_log=True,
)
def _create_log_entry(
action, instance, sender, diff_old, diff_new, fields_to_check=None, force_log=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
error = None
log_entry = None
changes = None
try:
changes = model_instance_diff(
diff_old, diff_new, fields_to_check=fields_to_check
)
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,
)
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
if action == "post_clear":
changed_queryset = kwargs["model"]._default_manager.all()
else:
changed_queryset = kwargs["model"]._default_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
django-auditlog-3.1.2/auditlog/registry.py 0000664 0000000 0000000 00000033374 15003135774 0020636 0 ustar 00root root 0000000 0000000 import copy
from collections import defaultdict
from collections.abc import Collection, Iterable
from typing import Any, Callable, Optional, Union
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 = ("auditlog.LogEntry", "admin.LogEntry")
def __init__(
self,
create: bool = True,
update: bool = True,
delete: bool = True,
access: bool = True,
m2m: bool = True,
custom: Optional[dict[ModelSignal, Callable]] = 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: Optional[list[str]] = None,
exclude_fields: Optional[list[str]] = None,
mapping_fields: Optional[dict[str, str]] = None,
mask_fields: Optional[list[str]] = None,
m2m_fields: Optional[Collection[str]] = None,
serialize_data: bool = False,
serialize_kwargs: Optional[dict[str, Any]] = 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 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,
"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"]),
}
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[Union[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 ."
)
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
)
self._register_models(settings.AUDITLOG_INCLUDE_TRACKING_MODELS)
auditlog = AuditlogModelRegistry()
django-auditlog-3.1.2/auditlog/signals.py 0000664 0000000 0000000 00000004501 15003135774 0020414 0 ustar 00root root 0000000 0000000 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.
"""
django-auditlog-3.1.2/auditlog_tests/ 0000775 0000000 0000000 00000000000 15003135774 0017624 5 ustar 00root root 0000000 0000000 django-auditlog-3.1.2/auditlog_tests/__init__.py 0000664 0000000 0000000 00000000000 15003135774 0021723 0 ustar 00root root 0000000 0000000 django-auditlog-3.1.2/auditlog_tests/admin.py 0000664 0000000 0000000 00000000215 15003135774 0021264 0 ustar 00root root 0000000 0000000 from django.contrib import admin
from auditlog.registry import auditlog
for model in auditlog.get_models():
admin.site.register(model)
django-auditlog-3.1.2/auditlog_tests/manage.py 0000664 0000000 0000000 00000000367 15003135774 0021434 0 ustar 00root root 0000000 0000000 #!/usr/bin/env python
import os
import sys
if __name__ == "__main__":
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_settings")
from django.core.management import execute_from_command_line
execute_from_command_line(sys.argv)
django-auditlog-3.1.2/auditlog_tests/test_app/ 0000775 0000000 0000000 00000000000 15003135774 0021443 5 ustar 00root root 0000000 0000000 django-auditlog-3.1.2/auditlog_tests/test_app/__init__.py 0000664 0000000 0000000 00000000000 15003135774 0023542 0 ustar 00root root 0000000 0000000 django-auditlog-3.1.2/auditlog_tests/test_app/apps.py 0000664 0000000 0000000 00000000137 15003135774 0022761 0 ustar 00root root 0000000 0000000 from django.apps import AppConfig
class AuditlogTestConfig(AppConfig):
name = "test_app"
django-auditlog-3.1.2/auditlog_tests/test_app/fixtures/ 0000775 0000000 0000000 00000000000 15003135774 0023314 5 ustar 00root root 0000000 0000000 django-auditlog-3.1.2/auditlog_tests/test_app/fixtures/custom_get_cid.py 0000664 0000000 0000000 00000000056 15003135774 0026657 0 ustar 00root root 0000000 0000000 def get_cid():
return "my custom get_cid"
django-auditlog-3.1.2/auditlog_tests/test_app/fixtures/m2m_test_fixture.json 0000664 0000000 0000000 00000000333 15003135774 0027506 0 ustar 00root root 0000000 0000000 [
{
"model": "test_app.manyrelatedmodel",
"pk": 1,
"fields": {
"recursive": [1],
"related": [1]
}
},
{
"model": "test_app.manyrelatedothermodel",
"pk": 1,
"fields": {}
}
] django-auditlog-3.1.2/auditlog_tests/test_app/models.py 0000664 0000000 0000000 00000031000 15003135774 0023272 0 ustar 00root root 0000000 0000000 import uuid
from django.contrib.postgres.fields import ArrayField
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)
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)
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)
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()
class AutoManyRelatedModel(models.Model):
related = models.ManyToManyField(SimpleModel)
auditlog.register(AltPrimaryKeyModel)
auditlog.register(UUIDPrimaryKeyModel)
auditlog.register(ModelPrimaryKeyModel)
auditlog.register(ProxyModel)
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"})
auditlog.register(SimpleExcludeModel, exclude_fields=["text"])
auditlog.register(SimpleMappingModel, mapping_fields={"sku": "Product No."})
auditlog.register(AdditionalDataIncludedModel)
auditlog.register(DateTimeFieldModel)
auditlog.register(ChoicesFieldModel)
auditlog.register(CharfieldTextfieldModel)
auditlog.register(PostgresArrayFieldModel)
auditlog.register(NoDeleteHistoryModel)
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},
)
django-auditlog-3.1.2/auditlog_tests/test_app/templates/ 0000775 0000000 0000000 00000000000 15003135774 0023441 5 ustar 00root root 0000000 0000000 django-auditlog-3.1.2/auditlog_tests/test_app/templates/simplemodel_detail.html 0000664 0000000 0000000 00000000000 15003135774 0030151 0 ustar 00root root 0000000 0000000 django-auditlog-3.1.2/auditlog_tests/test_app/urls.py 0000664 0000000 0000000 00000000436 15003135774 0023005 0 ustar 00root root 0000000 0000000 from django.contrib import admin
from django.urls import path
from .views import SimpleModelDetailView
urlpatterns = [
path("admin/", admin.site.urls),
path(
"simplemodel//",
SimpleModelDetailView.as_view(),
name="simplemodel-detail",
),
]
django-auditlog-3.1.2/auditlog_tests/test_app/views.py 0000664 0000000 0000000 00000000372 15003135774 0023154 0 ustar 00root root 0000000 0000000 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"
django-auditlog-3.1.2/auditlog_tests/test_commands.py 0000664 0000000 0000000 00000015654 15003135774 0023051 0 ustar 00root root 0000000 0000000 """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.test import TestCase, TransactionTestCase
from test_app.models import SimpleModel
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 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")
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")
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")
django-auditlog-3.1.2/auditlog_tests/test_settings.py 0000664 0000000 0000000 00000003102 15003135774 0023071 0 ustar 00root root 0000000 0000000 """
Settings file for the Auditlog test suite.
"""
import os
DEBUG = True
SECRET_KEY = "test"
INSTALLED_APPS = [
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.messages",
"django.contrib.sessions",
"django.contrib.admin",
"django.contrib.staticfiles",
"auditlog",
"test_app",
]
MIDDLEWARE = [
"django.middleware.common.CommonMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"auditlog.middleware.AuditlogMiddleware",
]
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"),
}
}
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"
django-auditlog-3.1.2/auditlog_tests/test_two_step_json_migration.py 0000664 0000000 0000000 00000013566 15003135774 0026216 0 ustar 00root root 0000000 0000000 import json
from io import StringIO
from unittest.mock import patch
from django.core.management import CommandError, call_command
from django.test import TestCase, override_settings
from test_app.models import SimpleModel
from auditlog.models import LogEntry
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
with patch("auditlog.models.LogEntry.objects.bulk_update") as bulk_update:
outbuf, errbuf = self.call_command("-b=1")
call_count = bulk_update.call_count
# Assert
self.assertEqual(call_count, 2)
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)
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)
django-auditlog-3.1.2/auditlog_tests/tests.py 0000664 0000000 0000000 00000316031 15003135774 0021344 0 ustar 00root root 0000000 0000000 import datetime
import itertools
import json
import random
import warnings
from datetime import timezone
from unittest import mock
from unittest.mock import patch
import freezegun
from dateutil.tz import gettz
from django.apps import apps
from django.conf import settings
from django.contrib.admin.sites import AdminSite
from django.contrib.auth import get_user_model
from django.contrib.auth.models import AnonymousUser, User
from django.contrib.contenttypes.models import ContentType
from django.core import management
from django.db import models
from django.db.models import JSONField, Value
from django.db.models.functions import Now
from django.db.models.signals import pre_save
from django.test import RequestFactory, TestCase, TransactionTestCase, override_settings
from django.urls import resolve, reverse
from django.utils import dateformat, 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 test_app.fixtures.custom_get_cid import get_cid as custom_get_cid
from test_app.models import (
AdditionalDataIncludedModel,
AltPrimaryKeyModel,
AutoManyRelatedModel,
CharfieldTextfieldModel,
ChoicesFieldModel,
DateTimeFieldModel,
JSONModel,
ManyRelatedModel,
ManyRelatedOtherModel,
ModelForReusableThroughModel,
ModelPrimaryKeyModel,
NoDeleteHistoryModel,
NullableJSONModel,
PostgresArrayFieldModel,
ProxyModel,
RelatedModel,
ReusableThroughRelatedModel,
SerializeNaturalKeyRelatedModel,
SerializeOnlySomeOfThisModel,
SerializePrimaryKeyRelatedModel,
SerializeThisModel,
SimpleExcludeModel,
SimpleIncludeModel,
SimpleMappingModel,
SimpleMaskedModel,
SimpleModel,
SimpleNonManagedModel,
SwappedManagerModel,
UUIDPrimaryKeyModel,
)
from auditlog.admin import LogEntryAdmin
from auditlog.cid import get_cid
from auditlog.context import disable_auditlog, set_actor
from auditlog.diff import model_instance_diff
from auditlog.middleware import AuditlogMiddleware
from auditlog.models import DEFAULT_OBJECT_REPR, LogEntry
from auditlog.registry import AuditlogModelRegistry, AuditLogRegistrationError, auditlog
from auditlog.signals import post_log, pre_log
class SimpleModelTest(TestCase):
def setUp(self):
self.obj = self.make_object()
super().setUp()
def make_object(self):
return SimpleModel.objects.create(text="I am not difficult.")
def test_create(self):
"""Creation is logged correctly."""
# Get the object to work with
obj = self.obj
# Check for log entries
self.assertEqual(obj.history.count(), 1, msg="There is one log entry")
history = obj.history.get()
self.check_create_log_entry(obj, history)
def check_create_log_entry(self, obj, history):
self.assertEqual(
history.action, LogEntry.Action.CREATE, msg="Action is 'CREATE'"
)
self.assertEqual(history.object_repr, str(obj), msg="Representation is equal")
def test_update(self):
"""Updates are logged correctly."""
# Get the object to work with
obj = self.obj
# Change something
self.update(obj)
# Check for log entries
self.assertEqual(
obj.history.filter(action=LogEntry.Action.UPDATE).count(),
1,
msg="There is one log entry for 'UPDATE'",
)
history = obj.history.get(action=LogEntry.Action.UPDATE)
self.check_update_log_entry(obj, history)
def update(self, obj):
obj.boolean = True
obj.save()
def check_update_log_entry(self, obj, history):
self.assertDictEqual(
history.changes,
{"boolean": ["False", "True"]},
msg="The change is correctly logged",
)
def test_update_specific_field_supplied_via_save_method(self):
obj = self.obj
# Change 2 fields, but save one only.
obj.boolean = True
obj.text = "Short text"
obj.save(update_fields=["boolean"])
# This implicitly asserts there is only one UPDATE change since the `.get` would fail otherwise.
self.assertDictEqual(
obj.history.get(action=LogEntry.Action.UPDATE).changes,
{"boolean": ["False", "True"]},
msg=(
"Object modifications that are not saved to DB are not logged "
"when using the `update_fields`."
),
)
def test_django_update_fields_edge_cases(self):
"""
The test ensures that if Django's `update_fields` behavior ever changes for special
values `(None, [])`, the package should too.
https://docs.djangoproject.com/en/3.2/ref/models/instances/#specifying-which-fields-to-save
"""
obj = self.obj
# Change boolean, but save no changes by passing an empty list.
obj.boolean = True
obj.save(update_fields=[])
self.assertEqual(
obj.history.filter(action=LogEntry.Action.UPDATE).count(),
0,
msg="There is no log entries created",
)
obj.refresh_from_db()
self.assertFalse(obj.boolean) # Change didn't persist in DB as expected.
# Passing `None` should save both fields according to Django.
obj.integer = 1
obj.boolean = True
obj.save(update_fields=None)
self.assertDictEqual(
obj.history.get(action=LogEntry.Action.UPDATE).changes,
{"boolean": ["False", "True"], "integer": ["None", "1"]},
msg="The 2 fields changed are correctly logged",
)
def test_delete(self):
"""Deletion is logged correctly."""
# Get the object to work with
obj = self.obj
content_type = ContentType.objects.get_for_model(obj.__class__)
pk = obj.pk
# Delete the object
self.delete(obj)
# Check for log entries
qs = LogEntry.objects.filter(content_type=content_type, object_pk=pk)
self.assertEqual(qs.count(), 1, msg="There is one log entry for 'DELETE'")
history = qs.get()
self.check_delete_log_entry(obj, history)
def delete(self, obj):
obj.delete()
def check_delete_log_entry(self, obj, history):
pass
def test_recreate(self):
self.obj.delete()
self.setUp()
self.test_create()
def test_create_log_to_object_from_other_database(self):
msg = "The log should not try to write to the same database as the object"
instance = self.obj
# simulate object obtained from a different database (read only)
instance._state.db = "replica"
changes = model_instance_diff(None, instance)
log_entry = LogEntry.objects.log_create(
instance,
action=LogEntry.Action.CREATE,
changes=json.dumps(changes),
)
self.assertEqual(
log_entry._state.db, "default", msg=msg
) # must be created in default database
def test_default_timestamp(self):
start = django_timezone.now()
self.test_recreate()
end = django_timezone.now()
history = self.obj.history.latest()
self.assertTrue(start <= history.timestamp <= end)
def test_manual_timestamp(self):
timestamp = datetime.datetime(1999, 12, 31, 23, 59, 59, tzinfo=timezone.utc)
LogEntry.objects.log_create(
instance=self.obj,
timestamp=timestamp,
changes="foo bar",
action=LogEntry.Action.UPDATE,
)
history = self.obj.history.filter(timestamp=timestamp, changes="foo bar")
self.assertTrue(history.exists())
def test_create_duplicate_with_pk_none(self):
initial_entries_count = LogEntry.objects.count()
obj = self.obj
obj.pk = None
obj.save()
self.assertEqual(LogEntry.objects.count(), initial_entries_count + 1)
class NoActorMixin:
def check_create_log_entry(self, obj, log_entry):
super().check_create_log_entry(obj, log_entry)
self.assertIsNone(log_entry.actor)
def check_update_log_entry(self, obj, log_entry):
super().check_update_log_entry(obj, log_entry)
self.assertIsNone(log_entry.actor)
def check_delete_log_entry(self, obj, log_entry):
super().check_delete_log_entry(obj, log_entry)
self.assertIsNone(log_entry.actor)
class WithActorMixin:
sequence = itertools.count()
def setUp(self):
username = f"actor_{next(self.sequence)}"
self.user = get_user_model().objects.create(
username=username,
email=f"{username}@example.com",
password="secret",
)
super().setUp()
def tearDown(self):
user_email = self.user.email
self.user.delete()
auditlog_entries = LogEntry.objects.filter(actor_email=user_email).all()
self.assertIsNotNone(auditlog_entries, msg="All auditlog entries are deleted.")
super().tearDown()
def make_object(self):
with set_actor(self.user):
return super().make_object()
def check_create_log_entry(self, obj, log_entry):
super().check_create_log_entry(obj, log_entry)
self.assertEqual(log_entry.actor, self.user)
self.assertEqual(log_entry.actor_email, self.user.email)
def update(self, obj):
with set_actor(self.user):
return super().update(obj)
def check_update_log_entry(self, obj, log_entry):
super().check_update_log_entry(obj, log_entry)
self.assertEqual(log_entry.actor, self.user)
self.assertEqual(log_entry.actor_email, self.user.email)
def delete(self, obj):
with set_actor(self.user):
return super().delete(obj)
def check_delete_log_entry(self, obj, log_entry):
super().check_delete_log_entry(obj, log_entry)
self.assertEqual(log_entry.actor, self.user)
self.assertEqual(log_entry.actor_email, self.user.email)
class AltPrimaryKeyModelBase(SimpleModelTest):
def make_object(self):
return AltPrimaryKeyModel.objects.create(
key=str(datetime.datetime.now()), text="I am strange."
)
class AltPrimaryKeyModelTest(NoActorMixin, AltPrimaryKeyModelBase):
pass
class AltPrimaryKeyModelWithActorTest(WithActorMixin, AltPrimaryKeyModelBase):
pass
class UUIDPrimaryKeyModelModelBase(SimpleModelTest):
def make_object(self):
return UUIDPrimaryKeyModel.objects.create(text="I am strange.")
def test_get_for_object(self):
self.obj.boolean = True
self.obj.save()
self.assertEqual(LogEntry.objects.get_for_object(self.obj).count(), 2)
def test_get_for_objects(self):
self.obj.boolean = True
self.obj.save()
self.assertEqual(
LogEntry.objects.get_for_objects(UUIDPrimaryKeyModel.objects.all()).count(),
2,
)
class UUIDPrimaryKeyModelModelTest(NoActorMixin, UUIDPrimaryKeyModelModelBase):
pass
class UUIDPrimaryKeyModelModelWithActorTest(
WithActorMixin, UUIDPrimaryKeyModelModelBase
):
pass
class ModelPrimaryKeyModelBase(SimpleModelTest):
def make_object(self):
self.key = super().make_object()
return ModelPrimaryKeyModel.objects.create(key=self.key, text="I am strange.")
def test_create_duplicate_with_pk_none(self):
pass
class ModelPrimaryKeyModelTest(NoActorMixin, ModelPrimaryKeyModelBase):
pass
class ModelPrimaryKeyModelWithActorTest(WithActorMixin, ModelPrimaryKeyModelBase):
pass
# Must inherit from TransactionTestCase to use self.assertNumQueries.
class ModelPrimaryKeyTest(TransactionTestCase):
def test_get_pk_value(self):
"""
Test that the primary key can be retrieved without additional database queries.
"""
key = SimpleModel.objects.create(text="I am not difficult.")
obj = ModelPrimaryKeyModel.objects.create(key=key, text="I am strange.")
# Refresh the object so the primary key object is not cached.
obj.refresh_from_db()
with self.assertNumQueries(0):
pk = LogEntry.objects._get_pk_value(obj)
self.assertEqual(pk, obj.pk)
self.assertEqual(pk, key.pk)
# Sanity check: verify accessing obj.key causes database access.
with self.assertNumQueries(1):
pk = obj.key.pk
self.assertEqual(pk, obj.pk)
self.assertEqual(pk, key.pk)
class ProxyModelBase(SimpleModelTest):
def make_object(self):
return ProxyModel.objects.create(text="I am not what you think.")
class ProxyModelTest(NoActorMixin, ProxyModelBase):
pass
class ProxyModelWithActorTest(WithActorMixin, ProxyModelBase):
pass
class ManyRelatedModelTest(TestCase):
"""
Test the behaviour of many-to-many relationships.
"""
def setUp(self):
self.obj = ManyRelatedModel.objects.create()
self.recursive = ManyRelatedModel.objects.create()
self.related = ManyRelatedOtherModel.objects.create()
self.obj_reusable = ModelForReusableThroughModel.objects.create()
self.obj_reusable_related = ReusableThroughRelatedModel.objects.create()
self.base_log_entry_count = (
LogEntry.objects.count()
) # created by the create() calls above
def test_recursive(self):
self.obj.recursive.add(self.recursive)
self.assertEqual(
LogEntry.objects.get_for_objects(self.obj.recursive.all()).first(),
self.recursive.history.first(),
)
def test_related_add_from_first_side(self):
self.obj.related.add(self.related)
self.assertEqual(
LogEntry.objects.get_for_objects(self.obj.related.all()).first(),
self.related.history.first(),
)
self.assertEqual(LogEntry.objects.count(), self.base_log_entry_count + 1)
def test_related_add_from_other_side(self):
self.related.related.add(self.obj)
self.assertEqual(
LogEntry.objects.get_for_objects(self.obj.related.all()).first(),
self.related.history.first(),
)
self.assertEqual(LogEntry.objects.count(), self.base_log_entry_count + 1)
def test_related_remove_from_first_side(self):
self.obj.related.add(self.related)
self.obj.related.remove(self.related)
self.assertEqual(LogEntry.objects.count(), self.base_log_entry_count + 2)
def test_related_remove_from_other_side(self):
self.related.related.add(self.obj)
self.related.related.remove(self.obj)
self.assertEqual(LogEntry.objects.count(), self.base_log_entry_count + 2)
def test_related_clear_from_first_side(self):
self.obj.related.add(self.related)
self.obj.related.clear()
self.assertEqual(LogEntry.objects.count(), self.base_log_entry_count + 2)
def test_related_clear_from_other_side(self):
self.related.related.add(self.obj)
self.related.related.clear()
self.assertEqual(LogEntry.objects.count(), self.base_log_entry_count + 2)
def test_additional_data(self):
self.obj.related.add(self.related)
log_entry = self.obj.history.first()
self.assertEqual(
log_entry.additional_data, {"related_model_id": self.related.id}
)
def test_changes(self):
self.obj.related.add(self.related)
log_entry = self.obj.history.first()
self.assertEqual(
log_entry.changes,
{
"related": {
"type": "m2m",
"operation": "add",
"objects": [smart_str(self.related)],
}
},
)
def test_adding_existing_related_obj(self):
self.obj.related.add(self.related)
log_entry = self.obj.history.first()
self.assertEqual(
log_entry.changes,
{
"related": {
"type": "m2m",
"operation": "add",
"objects": [smart_str(self.related)],
}
},
)
# Add same related obj again.
self.obj.related.add(self.related)
latest_log_entry = self.obj.history.first()
self.assertEqual(log_entry.id, latest_log_entry.id)
def test_object_repr_related_deleted(self):
"""No error is raised when __str__() raises ObjectDoesNotExist."""
# monkey-patching to avoid extra logic in the model
with mock.patch.object(self.obj.__class__, "__str__") as mock_str:
mock_str.side_effect = self.related.DoesNotExist("I am fake")
self.obj.related.add(self.related)
log_entry = self.obj.history.first()
self.assertEqual(log_entry.object_repr, DEFAULT_OBJECT_REPR)
def test_changes_not_duplicated_with_reusable_through_model(self):
self.obj_reusable.related.add(self.obj_reusable_related)
entries = self.obj_reusable.history.all()
self.assertEqual(len(entries), 1)
class MiddlewareTest(TestCase):
"""
Test the middleware responsible for connecting and disconnecting the signals used in automatic logging.
"""
def setUp(self):
self.get_response_mock = mock.Mock()
self.response_mock = mock.Mock()
self.middleware = AuditlogMiddleware(get_response=self.get_response_mock)
self.factory = RequestFactory()
self.user = User.objects.create_user(
username="test", email="test@example.com", password="top_secret"
)
def side_effect(self, assertion):
def inner(request):
assertion()
return self.response_mock
return inner
def assert_has_listeners(self):
self.assertTrue(pre_save.has_listeners(LogEntry))
def assert_no_listeners(self):
self.assertFalse(pre_save.has_listeners(LogEntry))
def test_request_anonymous(self):
"""No actor will be logged when a user is not logged in."""
request = self.factory.get("/")
request.user = AnonymousUser()
self.get_response_mock.side_effect = self.side_effect(self.assert_has_listeners)
response = self.middleware(request)
self.assertIs(response, self.response_mock)
self.get_response_mock.assert_called_once_with(request)
self.assert_no_listeners()
def test_request(self):
"""The actor will be logged when a user is logged in."""
request = self.factory.get("/")
request.user = self.user
self.get_response_mock.side_effect = self.side_effect(self.assert_has_listeners)
response = self.middleware(request)
self.assertIs(response, self.response_mock)
self.get_response_mock.assert_called_once_with(request)
self.assert_no_listeners()
def test_exception(self):
"""The signal will be disconnected when an exception is raised."""
request = self.factory.get("/")
request.user = self.user
SomeException = type("SomeException", (Exception,), {})
self.get_response_mock.side_effect = SomeException
with self.assertRaises(SomeException):
self.middleware(request)
self.assert_no_listeners()
def test_init_middleware(self):
with override_settings(AUDITLOG_DISABLE_REMOTE_ADDR="str"):
with self.assertRaisesMessage(
TypeError, "Setting 'AUDITLOG_DISABLE_REMOTE_ADDR' must be a boolean"
):
AuditlogMiddleware()
def test_disable_remote_addr(self):
with override_settings(AUDITLOG_DISABLE_REMOTE_ADDR=True):
headers = {"HTTP_X_FORWARDED_FOR": "127.0.0.2"}
request = self.factory.get("/", **headers)
remote_addr = self.middleware._get_remote_addr(request)
self.assertIsNone(remote_addr)
def test_get_remote_addr(self):
tests = [ # (headers, expected_remote_addr)
({}, "127.0.0.1"),
({"HTTP_X_FORWARDED_FOR": "127.0.0.2"}, "127.0.0.2"),
({"HTTP_X_FORWARDED_FOR": "127.0.0.3:1234"}, "127.0.0.3"),
({"HTTP_X_FORWARDED_FOR": "2606:4700:4700::1111"}, "2606:4700:4700::1111"),
(
{"HTTP_X_FORWARDED_FOR": "[2606:4700:4700::1001]:1234"},
"2606:4700:4700::1001",
),
]
for headers, expected_remote_addr in tests:
with self.subTest(headers=headers):
request = self.factory.get("/", **headers)
self.assertEqual(
self.middleware._get_remote_addr(request), expected_remote_addr
)
def test_get_remote_port(self):
headers = {
"HTTP_X_FORWARDED_PORT": "12345",
}
request = self.factory.get("/", **headers)
self.assertEqual(self.middleware._get_remote_port(request), 12345)
def test_cid(self):
header = str(settings.AUDITLOG_CID_HEADER).lstrip("HTTP_").replace("_", "-")
header_meta = "HTTP_" + header.upper().replace("-", "_")
cid = "random_CID"
_settings = [
# these tuples test reading the cid from the header defined in the settings
({"AUDITLOG_CID_HEADER": header}, cid), # x-correlation-id
({"AUDITLOG_CID_HEADER": header_meta}, cid), # HTTP_X_CORRELATION_ID
({"AUDITLOG_CID_HEADER": None}, None),
# these two tuples test using a custom getter.
# Here, we don't necessarily care about the cid that was set in set_cid
(
{"AUDITLOG_CID_GETTER": "test_app.fixtures.custom_get_cid.get_cid"},
custom_get_cid(),
),
({"AUDITLOG_CID_GETTER": custom_get_cid}, custom_get_cid()),
]
for setting, expected_result in _settings:
with self.subTest():
with self.settings(**setting):
request = self.factory.get("/", **{header_meta: cid})
self.middleware(request)
obj = SimpleModel.objects.create(text="I am not difficult.")
history = obj.history.get(action=LogEntry.Action.CREATE)
self.assertEqual(history.cid, expected_result)
self.assertEqual(get_cid(), expected_result)
def test_set_actor_anonymous_request(self):
"""
The remote address will be set even when there is no actor
"""
remote_addr = "123.213.145.99"
remote_port = 12345
actor = None
with set_actor(actor=actor, remote_addr=remote_addr, remote_port=remote_port):
obj = SimpleModel.objects.create(text="I am not difficult.")
history = obj.history.get()
self.assertEqual(
history.remote_addr,
remote_addr,
msg=f"Remote address is {remote_addr}",
)
self.assertEqual(
history.remote_port,
remote_port,
msg=f"Remote port is {remote_port}",
)
self.assertIsNone(history.actor, msg="Actor is `None` for anonymous user")
def test_get_actor(self):
params = [
(AnonymousUser(), None, "The user is anonymous so the actor is `None`"),
(self.user, self.user, "The use is authenticated so it is the actor"),
(None, None, "There is no actor"),
("1234", None, "The value of request.user is not a valid user model"),
]
for user, actor, msg in params:
with self.subTest(msg):
request = self.factory.get("/")
request.user = user
self.assertEqual(self.middleware._get_actor(request), actor)
class SimpleIncludeModelTest(TestCase):
"""Log only changes in include_fields"""
def test_specified_save_fields_are_ignored_if_not_included(self):
obj = SimpleIncludeModel.objects.create(label="Initial label", text="Text")
obj.text = "New text"
obj.save(update_fields=["text"])
self.assertEqual(
obj.history.filter(action=LogEntry.Action.UPDATE).count(),
0,
msg="Text change was not logged, even when passed explicitly",
)
obj.label = "New label"
obj.text = "Newer text"
obj.save(update_fields=["text", "label"])
self.assertDictEqual(
obj.history.get(action=LogEntry.Action.UPDATE).changes,
{"label": ["Initial label", "New label"]},
msg="Only the label was logged, regardless of multiple entries in `update_fields`",
)
def test_register_include_fields(self):
sim = SimpleIncludeModel(label="Include model", text="Looong text")
sim.save()
self.assertEqual(sim.history.count(), 1, msg="There is one log entry")
# Change label, record
sim.label = "Changed label"
sim.save()
self.assertEqual(sim.history.count(), 2, msg="There are two log entries")
# Change text, ignore
sim.text = "Short text"
sim.save()
self.assertEqual(sim.history.count(), 2, msg="There are two log entries")
class SimpleExcludeModelTest(TestCase):
"""Log only changes that are not in exclude_fields"""
def test_specified_save_fields_are_excluded_normally(self):
obj = SimpleExcludeModel.objects.create(label="Exclude model", text="Text")
obj.text = "New text"
obj.save(update_fields=["text"])
self.assertEqual(
obj.history.filter(action=LogEntry.Action.UPDATE).count(),
0,
msg="Text change was not logged, even when passed explicitly",
)
def test_register_exclude_fields(self):
sem = SimpleExcludeModel(label="Exclude model", text="Looong text")
sem.save()
self.assertEqual(sem.history.count(), 1, msg="There is one log entry")
# Change label, record it.
sem.label = "Changed label"
sem.save()
self.assertEqual(sem.history.count(), 2, msg="There are two log entries")
# Change text, ignore it.
sem.text = "Short text"
sem.save()
self.assertEqual(sem.history.count(), 2, msg="There are two log entries")
class SimpleMappingModelTest(TestCase):
"""Diff displays fields as mapped field names where available through mapping_fields"""
def test_register_mapping_fields(self):
smm = SimpleMappingModel(
sku="ASD301301A6", vtxt="2.1.5", not_mapped="Not mapped"
)
smm.save()
self.assertEqual(
smm.history.latest().changes_dict["sku"][1],
"ASD301301A6",
msg="The diff function retains 'sku' and can be retrieved.",
)
self.assertEqual(
smm.history.latest().changes_dict["not_mapped"][1],
"Not mapped",
msg="The diff function does not map 'not_mapped' and can be retrieved.",
)
self.assertEqual(
smm.history.latest().changes_display_dict["Product No."][1],
"ASD301301A6",
msg="The diff function maps 'sku' as 'Product No.' and can be retrieved.",
)
self.assertEqual(
smm.history.latest().changes_display_dict["Version"][1],
"2.1.5",
msg=(
"The diff function maps 'vtxt' as 'Version' through verbose_name"
" setting on the model field and can be retrieved."
),
)
self.assertEqual(
smm.history.latest().changes_display_dict["not mapped"][1],
"Not mapped",
msg=(
"The diff function uses the django default verbose name for 'not_mapped'"
" and can be retrieved."
),
)
class SimpleMaskedFieldsModelTest(TestCase):
"""Log masked changes for fields in mask_fields"""
def test_register_mask_fields(self):
smm = SimpleMaskedModel(address="Sensitive data", text="Looong text")
smm.save()
self.assertEqual(
smm.history.latest().changes_dict["address"][1],
"*******ve data",
msg="The diff function masks 'address' field.",
)
class AdditionalDataModelTest(TestCase):
"""Log additional data if get_additional_data is defined in the model"""
def test_model_without_additional_data(self):
obj_wo_additional_data = SimpleModel.objects.create(
text="No additional " "data"
)
obj_log_entry = obj_wo_additional_data.history.get()
self.assertIsNone(obj_log_entry.additional_data)
def test_model_with_additional_data(self):
related_model = SimpleModel.objects.create(text="Log my reference")
obj_with_additional_data = AdditionalDataIncludedModel(
label="Additional data to log entries", related=related_model
)
obj_with_additional_data.save()
self.assertEqual(
obj_with_additional_data.history.count(), 1, msg="There is 1 log entry"
)
log_entry = obj_with_additional_data.history.get()
extra_data = log_entry.additional_data
self.assertIsNotNone(extra_data)
self.assertEqual(
extra_data["related_model_text"],
related_model.text,
msg="Related model's text is logged",
)
self.assertEqual(
extra_data["related_model_id"],
related_model.id,
msg="Related model's id is logged",
)
class DateTimeFieldModelTest(TestCase):
"""Tests if DateTimeField changes are recognised correctly"""
utc_plus_one = django_timezone.get_fixed_timezone(datetime.timedelta(hours=1))
now = django_timezone.now()
def setUp(self):
super().setUp()
self._context = warnings.catch_warnings()
self._context.__enter__()
warnings.filterwarnings(
"ignore", message=".*naive datetime", category=RuntimeWarning
)
def tearDown(self):
self._context.__exit__()
super().tearDown()
def test_model_with_same_time(self):
timestamp = datetime.datetime(2017, 1, 10, 12, 0, tzinfo=timezone.utc)
date = datetime.date(2017, 1, 10)
time = datetime.time(12, 0)
dtm = DateTimeFieldModel(
label="DateTimeField model",
timestamp=timestamp,
date=date,
time=time,
naive_dt=self.now,
)
dtm.save()
self.assertEqual(dtm.history.count(), 1, msg="There is one log entry")
# Change timestamp to same datetime and timezone
timestamp = datetime.datetime(2017, 1, 10, 12, 0, tzinfo=timezone.utc)
dtm.timestamp = timestamp
dtm.date = datetime.date(2017, 1, 10)
dtm.time = datetime.time(12, 0)
dtm.save()
# Nothing should have changed
self.assertEqual(dtm.history.count(), 1, msg="There is one log entry")
def test_model_with_different_timezone(self):
timestamp = datetime.datetime(2017, 1, 10, 12, 0, tzinfo=timezone.utc)
date = datetime.date(2017, 1, 10)
time = datetime.time(12, 0)
dtm = DateTimeFieldModel(
label="DateTimeField model",
timestamp=timestamp,
date=date,
time=time,
naive_dt=self.now,
)
dtm.save()
self.assertEqual(dtm.history.count(), 1, msg="There is one log entry")
# Change timestamp to same datetime in another timezone
timestamp = datetime.datetime(2017, 1, 10, 13, 0, tzinfo=self.utc_plus_one)
dtm.timestamp = timestamp
dtm.save()
# Nothing should have changed
self.assertEqual(dtm.history.count(), 1, msg="There is one log entry")
def test_model_with_different_datetime(self):
timestamp = datetime.datetime(2017, 1, 10, 12, 0, tzinfo=timezone.utc)
date = datetime.date(2017, 1, 10)
time = datetime.time(12, 0)
dtm = DateTimeFieldModel(
label="DateTimeField model",
timestamp=timestamp,
date=date,
time=time,
naive_dt=self.now,
)
dtm.save()
self.assertEqual(dtm.history.count(), 1, msg="There is one log entry")
# Change timestamp to another datetime in the same timezone
timestamp = datetime.datetime(2017, 1, 10, 13, 0, tzinfo=timezone.utc)
dtm.timestamp = timestamp
dtm.save()
# The time should have changed.
self.assertEqual(dtm.history.count(), 2, msg="There are two log entries")
def test_model_with_different_date(self):
timestamp = datetime.datetime(2017, 1, 10, 12, 0, tzinfo=timezone.utc)
date = datetime.date(2017, 1, 10)
time = datetime.time(12, 0)
dtm = DateTimeFieldModel(
label="DateTimeField model",
timestamp=timestamp,
date=date,
time=time,
naive_dt=self.now,
)
dtm.save()
self.assertEqual(dtm.history.count(), 1, msg="There is one log entry")
# Change timestamp to another datetime in the same timezone
date = datetime.datetime(2017, 1, 11)
dtm.date = date
dtm.save()
# The time should have changed.
self.assertEqual(dtm.history.count(), 2, msg="There are two log entries")
def test_model_with_different_time(self):
timestamp = datetime.datetime(2017, 1, 10, 12, 0, tzinfo=timezone.utc)
date = datetime.date(2017, 1, 10)
time = datetime.time(12, 0)
dtm = DateTimeFieldModel(
label="DateTimeField model",
timestamp=timestamp,
date=date,
time=time,
naive_dt=self.now,
)
dtm.save()
self.assertEqual(dtm.history.count(), 1, msg="There is one log entry")
# Change timestamp to another datetime in the same timezone
time = datetime.time(6, 0)
dtm.time = time
dtm.save()
# The time should have changed.
self.assertEqual(dtm.history.count(), 2, msg="There are two log entries")
def test_model_with_different_time_and_timezone(self):
timestamp = datetime.datetime(2017, 1, 10, 12, 0, tzinfo=timezone.utc)
date = datetime.date(2017, 1, 10)
time = datetime.time(12, 0)
dtm = DateTimeFieldModel(
label="DateTimeField model",
timestamp=timestamp,
date=date,
time=time,
naive_dt=self.now,
)
dtm.save()
self.assertEqual(dtm.history.count(), 1, msg="There is one log entry")
# Change timestamp to another datetime and another timezone
timestamp = datetime.datetime(2017, 1, 10, 14, 0, tzinfo=self.utc_plus_one)
dtm.timestamp = timestamp
dtm.save()
# The time should have changed.
self.assertEqual(dtm.history.count(), 2, msg="There are two log entries")
def test_changes_display_dict_datetime(self):
timestamp = datetime.datetime(2017, 1, 10, 15, 0, tzinfo=timezone.utc)
date = datetime.date(2017, 1, 10)
time = datetime.time(12, 0)
dtm = DateTimeFieldModel(
label="DateTimeField model",
timestamp=timestamp,
date=date,
time=time,
naive_dt=self.now,
)
dtm.save()
localized_timestamp = timestamp.astimezone(gettz(settings.TIME_ZONE))
self.assertEqual(
dtm.history.latest().changes_display_dict["timestamp"][1],
dateformat.format(localized_timestamp, settings.DATETIME_FORMAT),
msg=(
"The datetime should be formatted according to Django's settings for"
" DATETIME_FORMAT"
),
)
timestamp = django_timezone.now()
dtm.timestamp = timestamp
dtm.save()
localized_timestamp = timestamp.astimezone(gettz(settings.TIME_ZONE))
self.assertEqual(
dtm.history.latest().changes_display_dict["timestamp"][1],
dateformat.format(localized_timestamp, settings.DATETIME_FORMAT),
msg=(
"The datetime should be formatted according to Django's settings for"
" DATETIME_FORMAT"
),
)
# Change USE_L10N = True
with self.settings(USE_L10N=True, LANGUAGE_CODE="en-GB"):
self.assertEqual(
dtm.history.latest().changes_display_dict["timestamp"][1],
formats.localize(localized_timestamp),
msg=(
"The datetime should be formatted according to Django's settings for"
" USE_L10N is True with a different LANGUAGE_CODE."
),
)
def test_changes_display_dict_date(self):
timestamp = datetime.datetime(2017, 1, 10, 15, 0, tzinfo=timezone.utc)
date = datetime.date(2017, 1, 10)
time = datetime.time(12, 0)
dtm = DateTimeFieldModel(
label="DateTimeField model",
timestamp=timestamp,
date=date,
time=time,
naive_dt=self.now,
)
dtm.save()
self.assertEqual(
dtm.history.latest().changes_display_dict["date"][1],
dateformat.format(date, settings.DATE_FORMAT),
msg=(
"The date should be formatted according to Django's settings for"
" DATE_FORMAT unless USE_L10N is True."
),
)
date = datetime.date(2017, 1, 11)
dtm.date = date
dtm.save()
self.assertEqual(
dtm.history.latest().changes_display_dict["date"][1],
dateformat.format(date, settings.DATE_FORMAT),
msg=(
"The date should be formatted according to Django's settings for"
" DATE_FORMAT unless USE_L10N is True."
),
)
# Change USE_L10N = True
with self.settings(USE_L10N=True, LANGUAGE_CODE="en-GB"):
self.assertEqual(
dtm.history.latest().changes_display_dict["date"][1],
formats.localize(date),
msg=(
"The date should be formatted according to Django's settings for"
" USE_L10N is True with a different LANGUAGE_CODE."
),
)
def test_changes_display_dict_time(self):
timestamp = datetime.datetime(2017, 1, 10, 15, 0, tzinfo=timezone.utc)
date = datetime.date(2017, 1, 10)
time = datetime.time(12, 0)
dtm = DateTimeFieldModel(
label="DateTimeField model",
timestamp=timestamp,
date=date,
time=time,
naive_dt=self.now,
)
dtm.save()
self.assertEqual(
dtm.history.latest().changes_display_dict["time"][1],
dateformat.format(time, settings.TIME_FORMAT),
msg=(
"The time should be formatted according to Django's settings for"
" TIME_FORMAT unless USE_L10N is True."
),
)
time = datetime.time(6, 0)
dtm.time = time
dtm.save()
self.assertEqual(
dtm.history.latest().changes_display_dict["time"][1],
dateformat.format(time, settings.TIME_FORMAT),
msg=(
"The time should be formatted according to Django's settings for"
" TIME_FORMAT unless USE_L10N is True."
),
)
# Change USE_L10N = True
with self.settings(USE_L10N=True, LANGUAGE_CODE="en-GB"):
self.assertEqual(
dtm.history.latest().changes_display_dict["time"][1],
formats.localize(time),
msg=(
"The time should be formatted according to Django's settings for"
" USE_L10N is True with a different LANGUAGE_CODE."
),
)
def test_update_naive_dt(self):
timestamp = datetime.datetime(2017, 1, 10, 15, 0, tzinfo=timezone.utc)
date = datetime.date(2017, 1, 10)
time = datetime.time(12, 0)
dtm = DateTimeFieldModel(
label="DateTimeField model",
timestamp=timestamp,
date=date,
time=time,
naive_dt=self.now,
)
dtm.save()
# Change with naive field doesnt raise error
dtm.naive_dt = django_timezone.make_naive(
django_timezone.now(), timezone=timezone.utc
)
dtm.save()
def test_datetime_field_functions_now(self):
timestamp = datetime.datetime(2017, 1, 10, 15, 0, tzinfo=timezone.utc)
date = datetime.date(2017, 1, 10)
time = datetime.time(12, 0)
dtm = DateTimeFieldModel(
label="DateTimeField model",
timestamp=timestamp,
date=date,
time=time,
naive_dt=Now(),
)
dtm.save()
dtm.naive_dt = Now()
self.assertEqual(dtm.naive_dt, Now())
dtm.save()
self.assertEqual(dtm.naive_dt, Now())
def test_json_field_value_none(self):
json_model = NullableJSONModel(json=Value(None, JSONField()))
json_model.save()
self.assertEqual(json_model.history.count(), 1)
self.assertEqual(
json_model.history.latest().changes_dict["json"][1], "Value(None)"
)
class UnregisterTest(TestCase):
def setUp(self):
auditlog.unregister(SimpleModel)
self.obj = SimpleModel.objects.create(text="No history")
def tearDown(self):
# Re-register for future tests
auditlog.register(SimpleModel)
def test_unregister_create(self):
"""Creation is not logged after unregistering."""
# Get the object to work with
obj = self.obj
# Check for log entries
self.assertEqual(obj.history.count(), 0, msg="There are no log entries")
def test_unregister_update(self):
"""Updates are not logged after unregistering."""
# Get the object to work with
obj = self.obj
# Change something
obj.boolean = True
obj.save()
# Check for log entries
self.assertEqual(obj.history.count(), 0, msg="There are no log entries")
def test_unregister_delete(self):
"""Deletion is not logged after unregistering."""
# Get the object to work with
obj = self.obj
# Delete the object
obj.delete()
# Check for log entries
self.assertEqual(LogEntry.objects.count(), 0, msg="There are no log entries")
def test_manual_logging(self):
obj = self.obj
obj.boolean = True
obj.save()
LogEntry.objects.log_create(
instance=obj,
action=LogEntry.Action.UPDATE,
changes="",
)
self.assertEqual(
obj.history.filter(action=LogEntry.Action.UPDATE).count(),
1,
msg="There is one log entry for 'UPDATE'",
)
class RegisterModelSettingsTest(TestCase):
def setUp(self):
self.test_auditlog = AuditlogModelRegistry()
def tearDown(self):
for model in self.test_auditlog.get_models():
self.test_auditlog.unregister(model)
def test_get_model_classes(self):
self.assertEqual(
len(list(self.test_auditlog._get_model_classes("auditlog"))),
len(list(apps.get_app_config("auditlog").get_models())),
)
self.assertEqual([], self.test_auditlog._get_model_classes("fake_model"))
def test_get_exclude_models(self):
# By default it returns DEFAULT_EXCLUDE_MODELS
self.assertEqual(len(self.test_auditlog._get_exclude_models(())), 2)
# Exclude just one model
self.assertTrue(
SimpleExcludeModel
in self.test_auditlog._get_exclude_models(("test_app.SimpleExcludeModel",))
)
# Exclude all model of an app
self.assertTrue(
SimpleExcludeModel in self.test_auditlog._get_exclude_models(("test_app",))
)
def test_register_models_no_models(self):
self.test_auditlog._register_models(())
self.assertEqual(self.test_auditlog._registry, {})
def test_register_models_register_single_model(self):
self.test_auditlog._register_models(("test_app.SimpleExcludeModel",))
self.assertTrue(self.test_auditlog.contains(SimpleExcludeModel))
self.assertEqual(len(self.test_auditlog._registry), 1)
def test_register_models_register_app(self):
self.test_auditlog._register_models(("test_app",))
self.assertTrue(self.test_auditlog.contains(SimpleExcludeModel))
self.assertTrue(self.test_auditlog.contains(ChoicesFieldModel))
self.assertEqual(len(self.test_auditlog.get_models()), 32)
def test_register_models_register_model_with_attrs(self):
self.test_auditlog._register_models(
(
{
"model": "test_app.SimpleExcludeModel",
"include_fields": ["label"],
"exclude_fields": [
"text",
],
},
)
)
self.assertTrue(self.test_auditlog.contains(SimpleExcludeModel))
fields = self.test_auditlog.get_model_fields(SimpleExcludeModel)
self.assertEqual(fields["include_fields"], ["label"])
self.assertEqual(fields["exclude_fields"], ["text"])
def test_register_models_register_model_with_m2m_fields(self):
self.test_auditlog._register_models(
(
{
"model": "test_app.ManyRelatedModel",
"m2m_fields": {"related"},
},
)
)
self.assertTrue(self.test_auditlog.contains(ManyRelatedModel))
self.assertEqual(
self.test_auditlog._registry[ManyRelatedModel]["m2m_fields"], {"related"}
)
def test_register_from_settings_invalid_settings(self):
with override_settings(AUDITLOG_INCLUDE_ALL_MODELS="str"):
with self.assertRaisesMessage(
TypeError, "Setting 'AUDITLOG_INCLUDE_ALL_MODELS' must be a boolean"
):
self.test_auditlog.register_from_settings()
with override_settings(AUDITLOG_EXCLUDE_TRACKING_MODELS="str"):
with self.assertRaisesMessage(
TypeError,
"Setting 'AUDITLOG_EXCLUDE_TRACKING_MODELS' must be a list or tuple",
):
self.test_auditlog.register_from_settings()
with override_settings(AUDITLOG_EXCLUDE_TRACKING_MODELS=("app1.model1",)):
with self.assertRaisesMessage(
ValueError,
"In order to use setting 'AUDITLOG_EXCLUDE_TRACKING_MODELS', "
"setting 'AUDITLOG_INCLUDE_ALL_MODELS' must set to 'True'",
):
self.test_auditlog.register_from_settings()
with override_settings(
AUDITLOG_INCLUDE_ALL_MODELS=True,
AUDITLOG_EXCLUDE_TRACKING_FIELDS="badvalue",
):
with self.assertRaisesMessage(
TypeError,
"Setting 'AUDITLOG_EXCLUDE_TRACKING_FIELDS' must be a list or tuple",
):
self.test_auditlog.register_from_settings()
with override_settings(
AUDITLOG_EXCLUDE_TRACKING_FIELDS=("created", "modified")
):
with self.assertRaisesMessage(
ValueError,
"In order to use 'AUDITLOG_EXCLUDE_TRACKING_FIELDS', "
"setting 'AUDITLOG_INCLUDE_ALL_MODELS' must be set to 'True'",
):
self.test_auditlog.register_from_settings()
with override_settings(
AUDITLOG_INCLUDE_ALL_MODELS=True,
AUDITLOG_MASK_TRACKING_FIELDS="badvalue",
):
with self.assertRaisesMessage(
TypeError,
"Setting 'AUDITLOG_MASK_TRACKING_FIELDS' must be a list or tuple",
):
self.test_auditlog.register_from_settings()
with override_settings(AUDITLOG_MASK_TRACKING_FIELDS=("token", "otp_secret")):
with self.assertRaisesMessage(
ValueError,
"In order to use 'AUDITLOG_MASK_TRACKING_FIELDS', "
"setting 'AUDITLOG_INCLUDE_ALL_MODELS' must be set to 'True'",
):
self.test_auditlog.register_from_settings()
with override_settings(AUDITLOG_INCLUDE_TRACKING_MODELS="str"):
with self.assertRaisesMessage(
TypeError,
"Setting 'AUDITLOG_INCLUDE_TRACKING_MODELS' must be a list or tuple",
):
self.test_auditlog.register_from_settings()
with override_settings(AUDITLOG_INCLUDE_TRACKING_MODELS=(1, 2)):
with self.assertRaisesMessage(
TypeError,
"Setting 'AUDITLOG_INCLUDE_TRACKING_MODELS' items must be str or dict",
):
self.test_auditlog.register_from_settings()
with override_settings(AUDITLOG_INCLUDE_TRACKING_MODELS=({"test": "test"},)):
with self.assertRaisesMessage(
ValueError,
"Setting 'AUDITLOG_INCLUDE_TRACKING_MODELS' dict items must contain 'model' key",
):
self.test_auditlog.register_from_settings()
with override_settings(AUDITLOG_INCLUDE_TRACKING_MODELS=({"model": "test"},)):
with self.assertRaisesMessage(
ValueError,
(
"Setting 'AUDITLOG_INCLUDE_TRACKING_MODELS' model must be in the "
"format ."
),
):
self.test_auditlog.register_from_settings()
with override_settings(
AUDITLOG_INCLUDE_TRACKING_MODELS=({"model": "notanapp.test"},)
):
with self.assertRaisesMessage(
AuditLogRegistrationError,
(
"An error was encountered while registering model 'notanapp.test'"
" - make sure the app is registered correctly."
),
):
self.test_auditlog.register_from_settings()
with override_settings(AUDITLOG_DISABLE_ON_RAW_SAVE="bad value"):
with self.assertRaisesMessage(
TypeError, "Setting 'AUDITLOG_DISABLE_ON_RAW_SAVE' must be a boolean"
):
self.test_auditlog.register_from_settings()
@override_settings(
AUDITLOG_INCLUDE_ALL_MODELS=True,
AUDITLOG_EXCLUDE_TRACKING_MODELS=("test_app.SimpleExcludeModel",),
)
def test_register_from_settings_register_all_models_with_exclude_models_tuple(self):
self.test_auditlog.register_from_settings()
self.assertFalse(self.test_auditlog.contains(SimpleExcludeModel))
self.assertTrue(self.test_auditlog.contains(ChoicesFieldModel))
@override_settings(
AUDITLOG_INCLUDE_ALL_MODELS=True,
AUDITLOG_EXCLUDE_TRACKING_FIELDS=("datetime",),
)
def test_register_from_settings_register_all_models_with_exclude_tracking_fields(
self,
):
self.test_auditlog.register_from_settings()
self.assertEqual(
self.test_auditlog.get_model_fields(SimpleModel)["exclude_fields"],
["datetime"],
)
self.assertEqual(
self.test_auditlog.get_model_fields(AltPrimaryKeyModel)["exclude_fields"],
["datetime"],
)
@override_settings(
AUDITLOG_INCLUDE_ALL_MODELS=True,
AUDITLOG_MASK_TRACKING_FIELDS=("secret",),
)
def test_register_from_settings_register_all_models_with_mask_tracking_fields(
self,
):
self.test_auditlog.register_from_settings()
self.assertEqual(
self.test_auditlog.get_model_fields(SimpleModel)["mask_fields"],
["secret"],
)
self.assertEqual(
self.test_auditlog.get_model_fields(AltPrimaryKeyModel)["mask_fields"],
["secret"],
)
@override_settings(
AUDITLOG_INCLUDE_ALL_MODELS=True,
AUDITLOG_EXCLUDE_TRACKING_MODELS=["test_app.SimpleExcludeModel"],
)
def test_register_from_settings_register_all_models_with_exclude_models_list(self):
self.test_auditlog.register_from_settings()
self.assertFalse(self.test_auditlog.contains(SimpleExcludeModel))
self.assertTrue(self.test_auditlog.contains(ChoicesFieldModel))
@override_settings(
AUDITLOG_INCLUDE_TRACKING_MODELS=(
{
"model": "test_app.SimpleExcludeModel",
"include_fields": ["label"],
"exclude_fields": [
"text",
],
},
)
)
def test_register_from_settings_register_models(self):
self.test_auditlog.register_from_settings()
self.assertTrue(self.test_auditlog.contains(SimpleExcludeModel))
fields = self.test_auditlog.get_model_fields(SimpleExcludeModel)
self.assertEqual(fields["include_fields"], ["label"])
self.assertEqual(fields["exclude_fields"], ["text"])
def test_registration_error_if_bad_serialize_params(self):
with self.assertRaisesMessage(
AuditLogRegistrationError,
"Serializer options were given but the 'serialize_data' option is not "
"set. Did you forget to set serialized_data to True?",
):
register = AuditlogModelRegistry()
register.register(
SimpleModel, serialize_kwargs={"fields": ["text", "integer"]}
)
@override_settings(AUDITLOG_INCLUDE_ALL_MODELS=True)
def test_register_from_settings_register_all_models_excluding_non_managed_models(
self,
):
self.test_auditlog.register_from_settings()
self.assertFalse(self.test_auditlog.contains(SimpleNonManagedModel))
@override_settings(AUDITLOG_INCLUDE_ALL_MODELS=True)
def test_register_from_settings_register_all_models_and_figure_out_m2m_fields(self):
self.test_auditlog.register_from_settings()
self.assertIn(
"related", self.test_auditlog._registry[AutoManyRelatedModel]["m2m_fields"]
)
@override_settings(AUDITLOG_INCLUDE_ALL_MODELS=True)
def test_register_from_settings_register_all_models_including_auto_created_models(
self,
):
self.test_auditlog.register_from_settings()
self.assertTrue(
self.test_auditlog.contains(AutoManyRelatedModel.related.through)
)
class ChoicesFieldModelTest(TestCase):
def setUp(self):
self.obj = ChoicesFieldModel.objects.create(
status=ChoicesFieldModel.RED,
multiplechoice=[
ChoicesFieldModel.RED,
ChoicesFieldModel.YELLOW,
ChoicesFieldModel.GREEN,
],
)
def test_changes_display_dict_single_choice(self):
self.assertEqual(
self.obj.history.latest().changes_display_dict["status"][1],
"Red",
msg="The human readable text 'Red' is displayed.",
)
self.obj.status = ChoicesFieldModel.GREEN
self.obj.save()
self.assertEqual(
self.obj.history.latest().changes_display_dict["status"][1],
"Green",
msg="The human readable text 'Green' is displayed.",
)
def test_changes_display_dict_multiplechoice(self):
self.assertEqual(
self.obj.history.latest().changes_display_dict["multiplechoice"][1],
"Red, Yellow, Green",
msg="The human readable text 'Red, Yellow, Green' is displayed.",
)
self.obj.multiplechoice = ChoicesFieldModel.RED
self.obj.save()
self.assertEqual(
self.obj.history.latest().changes_display_dict["multiplechoice"][1],
"Red",
msg="The human readable text 'Red' is displayed.",
)
def test_changes_display_dict_many_to_one_relation(self):
obj = SimpleModel()
obj.save()
history = obj.history.get()
assert "related_models" in history.changes_display_dict
class CharFieldTextFieldModelTest(TestCase):
def setUp(self):
self.PLACEHOLDER_LONGCHAR = "s" * 255
self.PLACEHOLDER_LONGTEXTFIELD = "s" * 1000
self.obj = CharfieldTextfieldModel.objects.create(
longchar=self.PLACEHOLDER_LONGCHAR,
longtextfield=self.PLACEHOLDER_LONGTEXTFIELD,
)
def test_changes_display_dict_longchar(self):
self.assertEqual(
self.obj.history.latest().changes_display_dict["longchar"][1],
f"{self.PLACEHOLDER_LONGCHAR[:140]}...",
msg="The string should be truncated at 140 characters with an ellipsis at the end.",
)
SHORTENED_PLACEHOLDER = self.PLACEHOLDER_LONGCHAR[:139]
self.obj.longchar = SHORTENED_PLACEHOLDER
self.obj.save()
self.assertEqual(
self.obj.history.latest().changes_display_dict["longchar"][1],
SHORTENED_PLACEHOLDER,
msg="The field should display the entire string because it is less than 140 characters",
)
def test_changes_display_dict_longtextfield(self):
self.assertEqual(
self.obj.history.latest().changes_display_dict["longtextfield"][1],
f"{self.PLACEHOLDER_LONGTEXTFIELD[:140]}...",
msg="The string should be truncated at 140 characters with an ellipsis at the end.",
)
SHORTENED_PLACEHOLDER = self.PLACEHOLDER_LONGTEXTFIELD[:139]
self.obj.longtextfield = SHORTENED_PLACEHOLDER
self.obj.save()
self.assertEqual(
self.obj.history.latest().changes_display_dict["longtextfield"][1],
SHORTENED_PLACEHOLDER,
msg="The field should display the entire string because it is less than 140 characters",
)
def test_changes_display_dict_longtextfield_to_be_truncated_at_custom_length(self):
with override_settings(AUDITLOG_CHANGE_DISPLAY_TRUNCATE_LENGTH=10):
length = settings.AUDITLOG_CHANGE_DISPLAY_TRUNCATE_LENGTH
self.assertEqual(
self.obj.history.latest().changes_display_dict["longtextfield"][1],
f"{self.PLACEHOLDER_LONGCHAR[:length]}...",
msg=f"The string should be truncated at {length} characters with an ellipsis at the end.",
)
def test_changes_display_dict_longtextfield_to_be_truncated_to_empty_string(self):
with override_settings(AUDITLOG_CHANGE_DISPLAY_TRUNCATE_LENGTH=0):
length = settings.AUDITLOG_CHANGE_DISPLAY_TRUNCATE_LENGTH
self.assertEqual(
self.obj.history.latest().changes_display_dict["longtextfield"][1],
"",
msg=f"The string should be empty as AUDITLOG_TRUNCATE_CHANGES_DISPLAY is set to {length}.",
)
def test_changes_display_dict_longtextfield_with_truncation_disabled(self):
with override_settings(AUDITLOG_CHANGE_DISPLAY_TRUNCATE_LENGTH=-1):
length = settings.AUDITLOG_CHANGE_DISPLAY_TRUNCATE_LENGTH
self.assertEqual(
self.obj.history.latest().changes_display_dict["longtextfield"][1],
self.PLACEHOLDER_LONGTEXTFIELD,
msg=(
"The field should display the entire string "
f"even though it is longer than {length} characters"
"as AUDITLOG_TRUNCATE_CHANGES_DISPLAY is set to a negative number"
),
)
class PostgresArrayFieldModelTest(TestCase):
databases = "__all__"
def setUp(self):
self.obj = PostgresArrayFieldModel.objects.create(
arrayfield=[PostgresArrayFieldModel.RED, PostgresArrayFieldModel.GREEN],
)
@property
def latest_array_change(self):
return self.obj.history.latest().changes_display_dict["arrayfield"][1]
def test_changes_display_dict_arrayfield(self):
self.assertEqual(
self.latest_array_change,
"Red, Green",
msg="The human readable text for the two choices, 'Red, Green' is displayed.",
)
self.obj.arrayfield = [PostgresArrayFieldModel.GREEN]
self.obj.save()
self.assertEqual(
self.latest_array_change,
"Green",
msg="The human readable text 'Green' is displayed.",
)
self.obj.arrayfield = []
self.obj.save()
self.assertEqual(
self.latest_array_change,
"",
msg="The human readable text '' is displayed.",
)
self.obj.arrayfield = [PostgresArrayFieldModel.GREEN]
self.obj.save()
self.assertEqual(
self.latest_array_change,
"Green",
msg="The human readable text 'Green' is displayed.",
)
class AdminPanelTest(TestCase):
def setUp(self):
self.user = User.objects.create_user(
username="test_admin", is_staff=True, is_superuser=True, is_active=True
)
self.site = AdminSite()
self.admin = LogEntryAdmin(LogEntry, self.site)
with freezegun.freeze_time("2022-08-01 12:00:00Z"):
self.obj = SimpleModel.objects.create(text="For admin logentry test")
def test_auditlog_admin(self):
self.client.force_login(self.user)
log_pk = self.obj.history.latest().pk
res = self.client.get("/admin/auditlog/logentry/")
self.assertEqual(res.status_code, 200)
res = self.client.get("/admin/auditlog/logentry/add/")
self.assertEqual(res.status_code, 403)
res = self.client.get(f"/admin/auditlog/logentry/{log_pk}/", follow=True)
self.assertEqual(res.status_code, 200)
res = self.client.get(f"/admin/auditlog/logentry/{log_pk}/delete/")
self.assertEqual(res.status_code, 403)
res = self.client.get(f"/admin/auditlog/logentry/{log_pk}/history/")
self.assertEqual(res.status_code, 200)
def test_created_timezone(self):
log_entry = self.obj.history.latest()
for tz, timestamp in [
("UTC", "2022-08-01 12:00:00"),
("Asia/Tbilisi", "2022-08-01 16:00:00"),
("America/Buenos_Aires", "2022-08-01 09:00:00"),
("Asia/Kathmandu", "2022-08-01 17:45:00"),
]:
with self.settings(TIME_ZONE=tz):
created = self.admin.created(log_entry)
self.assertEqual(created.strftime("%Y-%m-%d %H:%M:%S"), timestamp)
@freezegun.freeze_time("2022-08-01 12:00:00Z")
def test_created_naive_datetime(self):
with self.settings(USE_TZ=False):
obj = SimpleModel.objects.create(text="For USE_TZ=False test")
log_entry = obj.history.latest()
created = self.admin.created(log_entry)
self.assertEqual(
created.strftime("%Y-%m-%d %H:%M:%S"),
"2022-08-01 12:00:00",
)
def test_cid(self):
self.client.force_login(self.user)
expected_response = (
'123'
)
log_entry = self.obj.history.latest()
log_entry.cid = "123"
log_entry.save()
res = self.client.get("/admin/auditlog/logentry/")
self.assertEqual(res.status_code, 200)
self.assertIn(expected_response, res.rendered_content)
def test_has_delete_permission(self):
log = self.obj.history.latest()
obj_pk = self.obj.pk
delete_log_request = RequestFactory().post(
f"/admin/auditlog/logentry/{log.pk}/delete/"
)
delete_log_request.resolver_match = resolve(delete_log_request.path)
delete_log_request.user = self.user
delete_object_request = RequestFactory().post(
f"/admin/tests/simplemodel/{obj_pk}/delete/"
)
delete_object_request.resolver_match = resolve(delete_object_request.path)
delete_object_request.user = self.user
self.assertTrue(self.admin.has_delete_permission(delete_object_request, log))
self.assertFalse(self.admin.has_delete_permission(delete_log_request, log))
class DiffMsgTest(TestCase):
def setUp(self):
super().setUp()
self.site = AdminSite()
self.admin = LogEntryAdmin(LogEntry, self.site)
def _create_log_entry(self, action, changes):
return LogEntry.objects.log_create(
SimpleModel.objects.create(), # doesn't affect anything
action=action,
changes=changes,
)
def test_change_msg_create_when_exceeds_max_len(self):
log_entry = self._create_log_entry(
LogEntry.Action.CREATE,
{
"Camelopardalis": [None, "Giraffe"],
"Capricornus": [None, "Sea goat"],
"Equuleus": [None, "Little horse"],
"Horologium": [None, "Clock"],
"Microscopium": [None, "Microscope"],
"Reticulum": [None, "Net"],
"Telescopium": [None, "Telescope"],
},
)
self.assertEqual(
self.admin.msg_short(log_entry),
"7 changes: Camelopardalis, Capricornus, Equuleus, Horologium, "
"Microscopium, ..",
)
def test_changes_msg_delete(self):
log_entry = self._create_log_entry(
LogEntry.Action.DELETE,
{"field one": ["value before deletion", None], "field two": [11, None]},
)
self.assertEqual(self.admin.msg_short(log_entry), "")
self.assertEqual(
self.admin.msg(log_entry),
(
""
"# | Field | From | To |
"
"1 | Field one | value before deletion | None |
"
"2 | Field two | 11 | None |
"
"
"
),
)
def test_instance_translation_and_history_logging(self):
first = SimpleModel()
second = SimpleModel(text=_("test"))
changes = model_instance_diff(first, second)
self.assertEqual(changes, {"text": ("", "test")})
second.save()
log_one = second.history.last()
self.assertTrue(isinstance(log_one, LogEntry))
def test_changes_msg_create(self):
log_entry = self._create_log_entry(
LogEntry.Action.CREATE,
{
"field two": [None, 11],
"field one": [None, "a value"],
},
)
self.assertEqual(
self.admin.msg_short(log_entry), "2 changes: field two, field one"
)
self.assertEqual(
self.admin.msg(log_entry),
(
""
"# | Field | From | To |
"
"1 | Field one | None | a value |
"
"2 | Field two | None | 11 |
"
"
"
),
)
def test_changes_msg_update(self):
log_entry = self._create_log_entry(
LogEntry.Action.UPDATE,
{
"field two": [11, 42],
"field one": ["old value of field one", "new value of field one"],
},
)
self.assertEqual(
self.admin.msg_short(log_entry), "2 changes: field two, field one"
)
self.assertEqual(
self.admin.msg(log_entry),
(
""
"# | Field | From | To |
"
"1 | Field one | old value of field one | "
"new value of field one |
"
"2 | Field two | 11 | 42 |
"
"
"
),
)
def test_changes_msg_m2m(self):
log_entry = self._create_log_entry(
LogEntry.Action.UPDATE,
{ # mimicking the format used by log_m2m_changes
"some_m2m_field": {
"type": "m2m",
"operation": "add",
"objects": ["Example User (user 1)", "Illustration (user 42)"],
},
},
)
self.assertEqual(self.admin.msg_short(log_entry), "1 change: some_m2m_field")
self.assertEqual(
self.admin.msg(log_entry),
(
""
"# | Relationship | Action | Objects |
"
"1 | Some m2m field | add | Example User (user 1)"
" Illustration (user 42) |
"
"
"
),
)
def test_unregister_after_log(self):
log_entry = self._create_log_entry(
LogEntry.Action.CREATE,
{
"field two": [None, 11],
"field one": [None, "a value"],
},
)
# Unregister
auditlog.unregister(SimpleModel)
self.assertEqual(
self.admin.msg_short(log_entry), "2 changes: field two, field one"
)
self.assertEqual(
self.admin.msg(log_entry),
(
""
"# | Field | From | To |
"
"1 | Field one | None | a value |
"
"2 | Field two | None | 11 |
"
"
"
),
)
# Re-register
auditlog.register(SimpleModel)
def test_field_verbose_name(self):
log_entry = self._create_log_entry(
LogEntry.Action.CREATE,
{"test": "test"},
)
self.assertEqual(self.admin.field_verbose_name(log_entry, "actor"), "Actor")
with patch(
"django.contrib.contenttypes.models.ContentType.model_class",
return_value=None,
):
self.assertEqual(self.admin.field_verbose_name(log_entry, "actor"), "actor")
class NoDeleteHistoryTest(TestCase):
def test_delete_related(self):
instance = SimpleModel.objects.create(integer=1)
assert LogEntry.objects.all().count() == 1
instance.integer = 2
instance.save()
assert LogEntry.objects.all().count() == 2
instance.delete()
entries = LogEntry.objects.order_by("id")
# The "DELETE" record is always retained
assert LogEntry.objects.all().count() == 1
assert entries.first().action == LogEntry.Action.DELETE
def test_no_delete_related(self):
instance = NoDeleteHistoryModel.objects.create(integer=1)
self.assertEqual(LogEntry.objects.all().count(), 1)
instance.integer = 2
instance.save()
self.assertEqual(LogEntry.objects.all().count(), 2)
instance.delete()
entries = LogEntry.objects.order_by("id")
self.assertEqual(entries.count(), 3)
self.assertEqual(
list(entries.values_list("action", flat=True)),
[LogEntry.Action.CREATE, LogEntry.Action.UPDATE, LogEntry.Action.DELETE],
)
class JSONModelTest(TestCase):
def setUp(self):
self.obj = JSONModel.objects.create()
def test_update(self):
"""Changes on a JSONField are logged correctly."""
# Get the object to work with
obj = self.obj
# Change something
obj.json = {
"quantity": "1",
}
obj.save()
# Check for log entries
self.assertEqual(
obj.history.filter(action=LogEntry.Action.UPDATE).count(),
1,
msg="There is one log entry for 'UPDATE'",
)
history = obj.history.get(action=LogEntry.Action.UPDATE)
self.assertDictEqual(
history.changes,
{"json": ["{}", '{"quantity": "1"}']},
msg="The change is correctly logged",
)
def test_update_with_no_changes(self):
"""No changes are logged."""
first_json = {
"quantity": "1814",
"tax_rate": "17",
"unit_price": "144",
"description": "Method form.",
"discount_rate": "42",
"unit_of_measure": "bytes",
}
obj = JSONModel.objects.create(json=first_json)
# Change the order of the keys but not the values
second_json = {
"tax_rate": "17",
"description": "Method form.",
"quantity": "1814",
"unit_of_measure": "bytes",
"unit_price": "144",
"discount_rate": "42",
}
obj.json = second_json
obj.save()
# Check for log entries
self.assertEqual(
first_json,
second_json,
msg="dicts are the same",
)
self.assertEqual(
obj.history.filter(action=LogEntry.Action.UPDATE).count(),
0,
msg="There is no log entry",
)
class ModelInstanceDiffTest(TestCase):
def test_diff_models_with_related_fields(self):
"""No error is raised when comparing models with related fields."""
# This tests that track_field() does indeed ignore related fields.
# a model without reverse relations
simple1 = SimpleModel()
simple1.save()
# a model with reverse relations
simple2 = SimpleModel()
simple2.save()
related = RelatedModel(related=simple2, one_to_one=simple2)
related.save()
# Demonstrate that simple1 can have DoesNotExist on reverse
# OneToOne relation.
with self.assertRaises(
SimpleModel.reverse_one_to_one.RelatedObjectDoesNotExist
):
simple1.reverse_one_to_one # equals None
# accessing relatedmodel_set won't trigger DoesNotExist.
self.assertEqual(simple1.related_models.count(), 0)
# simple2 DOES have these relations
self.assertEqual(simple2.reverse_one_to_one, related)
self.assertEqual(simple2.related_models.count(), 1)
model_instance_diff(simple2, simple1)
model_instance_diff(simple1, simple2)
def test_object_repr_related_deleted(self):
"""No error is raised when __str__() loads a related object that has been deleted."""
simple = SimpleModel()
simple.save()
related = RelatedModel(related=simple, one_to_one=simple)
related.save()
related_id = related.id
related.refresh_from_db()
simple.delete()
related.delete()
log_entry = (
LogEntry.objects.get_for_model(RelatedModel)
.filter(object_id=related_id)
.get(action=LogEntry.Action.DELETE)
)
self.assertEqual(log_entry.object_repr, DEFAULT_OBJECT_REPR)
def test_when_field_doesnt_exist(self):
"""No error is raised and the default is returned."""
first = SimpleModel(boolean=True)
second = SimpleModel()
# then boolean should be False, as we use the default value
# specified inside the model
del second.boolean
changes = model_instance_diff(first, second)
# Check for log entries
self.assertEqual(
changes,
{"boolean": ("True", "False")},
msg="ObjectDoesNotExist should be handled",
)
def test_diff_models_with_json_fields(self):
first = JSONModel.objects.create(
json={
"code": "17",
"date": datetime.date(2022, 1, 1),
"description": "first",
}
)
first.refresh_from_db() # refresh json data from db
second = JSONModel.objects.create(
json={
"code": "17",
"description": "second",
"date": datetime.date(2023, 1, 1),
}
)
diff = model_instance_diff(first, second, ["json"])
self.assertDictEqual(
diff,
{
"json": (
'{"code": "17", "date": "2022-01-01", "description": "first"}',
'{"code": "17", "date": "2023-01-01", "description": "second"}',
)
},
)
class TestRelatedDiffs(TestCase):
def setUp(self):
self.test_date = datetime.datetime(2022, 1, 1, 12, tzinfo=datetime.timezone.utc)
def test_log_entry_changes_on_fk_object_update(self):
t1 = self.test_date
with freezegun.freeze_time(t1):
simple = SimpleModel.objects.create()
one_simple = SimpleModel.objects.create()
two_simple = SimpleModel.objects.create()
instance = RelatedModel.objects.create(
one_to_one=simple, related=one_simple
)
t2 = self.test_date + datetime.timedelta(days=20)
with freezegun.freeze_time(t2):
instance.related = two_simple
instance.save()
log_one = instance.history.filter(timestamp=t1).first()
log_two = instance.history.filter(timestamp=t2).first()
self.assertTrue(isinstance(log_one, LogEntry))
self.assertTrue(isinstance(log_two, LogEntry))
self.assertEqual(int(log_one.changes_dict["related"][1]), one_simple.id)
self.assertEqual(int(log_one.changes_dict["one_to_one"][1]), simple.id)
self.assertEqual(int(log_two.changes_dict["related"][1]), two_simple.id)
def test_log_entry_changes_on_fk_object_id_update(self):
t1 = self.test_date
with freezegun.freeze_time(t1):
simple = SimpleModel.objects.create()
one_simple = SimpleModel.objects.create()
two_simple = SimpleModel.objects.create()
instance = RelatedModel.objects.create(
one_to_one=simple, related=one_simple
)
t2 = self.test_date + datetime.timedelta(days=20)
with freezegun.freeze_time(t2):
instance.related_id = two_simple.id
instance.one_to_one = one_simple
instance.save(update_fields=["related_id", "one_to_one_id"])
log_one = instance.history.filter(timestamp=t1).first()
log_two = instance.history.filter(timestamp=t2).first()
self.assertTrue(isinstance(log_one, LogEntry))
self.assertTrue(isinstance(log_two, LogEntry))
self.assertEqual(int(log_one.changes_dict["related"][1]), one_simple.id)
self.assertEqual(int(log_one.changes_dict["one_to_one"][1]), simple.id)
self.assertEqual(int(log_two.changes_dict["related"][1]), two_simple.id)
self.assertEqual(int(log_two.changes_dict["one_to_one"][1]), one_simple.id)
def test_log_entry_changes_on_fk_id_update(self):
t1 = self.test_date
with freezegun.freeze_time(t1):
simple = SimpleModel.objects.create()
one_simple = SimpleModel.objects.create()
two_simple = SimpleModel.objects.create()
instance = RelatedModel.objects.create(
one_to_one_id=int(simple.id), related_id=int(one_simple.id)
)
t2 = self.test_date + datetime.timedelta(days=20)
with freezegun.freeze_time(t2):
instance.related_id = int(two_simple.id)
instance.save()
log_one = instance.history.filter(timestamp=t1).first()
log_two = instance.history.filter(timestamp=t2).first()
self.assertTrue(isinstance(log_one, LogEntry))
self.assertTrue(isinstance(log_two, LogEntry))
self.assertEqual(int(log_one.changes_dict["related"][1]), one_simple.id)
self.assertEqual(int(log_one.changes_dict["one_to_one"][1]), simple.id)
self.assertEqual(int(log_two.changes_dict["related"][1]), two_simple.id)
def test_log_entry_create_fk_changes_to_string_objects_in_display_dict(self):
t1 = self.test_date
with freezegun.freeze_time(t1):
simple = SimpleModel.objects.create(text="Test Foo")
one_simple = SimpleModel.objects.create(text="Test Bar")
instance = RelatedModel.objects.create(
one_to_one=simple, related=one_simple
)
log_one = instance.history.filter(timestamp=t1).first()
self.assertTrue(isinstance(log_one, LogEntry))
display_dict = log_one.changes_display_dict
self.assertEqual(display_dict["related"][1], "Test Bar")
self.assertEqual(display_dict["related"][0], "None")
self.assertEqual(display_dict["one to one"][1], "Test Foo")
def test_log_entry_deleted_fk_changes_to_string_objects_in_display_dict(self):
t1 = self.test_date
with freezegun.freeze_time(t1):
simple = SimpleModel.objects.create(text="Test Foo")
one_simple = SimpleModel.objects.create(text="Test Bar")
one_simple_id = int(one_simple.id)
instance = RelatedModel.objects.create(
one_to_one=simple, related=one_simple
)
t2 = self.test_date + datetime.timedelta(days=20)
with freezegun.freeze_time(t2):
one_simple.delete()
log_two = LogEntry.objects.filter(object_id=instance.id, timestamp=t2).first()
self.assertTrue(isinstance(log_two, LogEntry))
display_dict = log_two.changes_display_dict
self.assertEqual(
display_dict["related"][0], f"Deleted 'SimpleModel' ({one_simple_id})"
)
self.assertEqual(display_dict["related"][1], "None")
def test_no_log_entry_created_on_related_object_string_update(self):
t1 = self.test_date
with freezegun.freeze_time(t1):
simple = SimpleModel.objects.create(text="Test Foo")
one_simple = SimpleModel.objects.create(text="Test Bar")
instance = RelatedModel.objects.create(
one_to_one=simple, related=one_simple
)
t2 = self.test_date + datetime.timedelta(days=20)
with freezegun.freeze_time(t2):
# Order is important. Without special FK handling, the arbitrary in memory
# changes to the (same) related object's signature result in a perceived
# update where no update has occurred.
one_simple.text = "Test Baz"
instance.save()
one_simple.save()
# Assert that only one log for the instance was created
self.assertEqual(instance.history.all().count(), 1)
# Assert that two logs were created for the parent object
self.assertEqual(one_simple.history.all().count(), 2)
def test_log_entry_created_if_obj_strings_are_same_for_two_objs(self):
"""FK changes trigger update when the string representation is the same."""
t1 = self.test_date
with freezegun.freeze_time(t1):
simple = SimpleModel.objects.create(text="Test Foo")
one_simple = SimpleModel.objects.create(text="Twinsies", boolean=True)
two_simple = SimpleModel.objects.create(text="Twinsies", boolean=False)
instance = RelatedModel.objects.create(
one_to_one=simple, related=one_simple
)
t2 = self.test_date + datetime.timedelta(days=20)
with freezegun.freeze_time(t2):
instance.related = two_simple
instance.save()
self.assertEqual(instance.history.all().count(), 2)
log_create = instance.history.filter(timestamp=t1).first()
log_update = instance.history.filter(timestamp=t2).first()
self.assertEqual(int(log_create.changes_dict["related"][1]), one_simple.id)
self.assertEqual(int(log_update.changes_dict["related"][1]), two_simple.id)
class TestModelSerialization(TestCase):
def setUp(self):
super().setUp()
self.test_date = datetime.datetime(2022, 1, 1, 12, tzinfo=timezone.utc)
self.test_date_string = datetime.datetime.strftime(
self.test_date, "%Y-%m-%dT%XZ"
)
def test_does_not_serialize_data_when_not_configured(self):
instance = SimpleModel.objects.create(
text="sample text here", boolean=True, integer=4
)
log = instance.history.first()
self.assertIsNone(log.serialized_data)
def test_serializes_data_on_create(self):
with freezegun.freeze_time(self.test_date):
instance = SerializeThisModel.objects.create(
label="test label",
timestamp=self.test_date,
nullable=4,
nested={"foo": True, "bar": False},
)
log = instance.history.first()
self.assertTrue(isinstance(log, LogEntry))
self.assertEqual(log.action, 0)
self.assertDictEqual(
log.serialized_data["fields"],
{
"label": "test label",
"timestamp": self.test_date_string,
"nullable": 4,
"nested": {"foo": True, "bar": False},
"mask_me": None,
"date": None,
"code": None,
},
)
def test_serializes_data_on_update(self):
with freezegun.freeze_time(self.test_date):
instance = SerializeThisModel.objects.create(
label="test label",
timestamp=self.test_date,
nullable=4,
nested={"foo": True, "bar": False},
)
update_date = self.test_date + datetime.timedelta(days=4)
with freezegun.freeze_time(update_date):
instance.label = "test label change"
instance.save()
log = instance.history.filter(timestamp=update_date).first()
self.assertTrue(isinstance(log, LogEntry))
self.assertEqual(log.action, 1)
self.assertDictEqual(
log.serialized_data["fields"],
{
"label": "test label change",
"timestamp": self.test_date_string,
"nullable": 4,
"nested": {"foo": True, "bar": False},
"mask_me": None,
"date": None,
"code": None,
},
)
def test_serializes_data_on_delete(self):
with freezegun.freeze_time(self.test_date):
instance = SerializeThisModel.objects.create(
label="test label",
timestamp=self.test_date,
nullable=4,
nested={"foo": True, "bar": False},
)
obj_id = int(instance.id)
delete_date = self.test_date + datetime.timedelta(days=4)
with freezegun.freeze_time(delete_date):
instance.delete()
log = LogEntry.objects.filter(object_id=obj_id, timestamp=delete_date).first()
self.assertTrue(isinstance(log, LogEntry))
self.assertEqual(log.action, 2)
self.assertDictEqual(
log.serialized_data["fields"],
{
"label": "test label",
"timestamp": self.test_date_string,
"nullable": 4,
"nested": {"foo": True, "bar": False},
"mask_me": None,
"date": None,
"code": None,
},
)
def test_serialize_string_representations(self):
with freezegun.freeze_time(self.test_date):
instance = SerializeThisModel.objects.create(
label="test label",
nullable=4,
nested={"foo": 10, "bar": False},
timestamp="2022-03-01T12:00Z",
date="2022-04-05",
code="e82d5e53-ca80-4037-af55-b90752326460",
)
log = instance.history.first()
self.assertTrue(isinstance(log, LogEntry))
self.assertEqual(log.action, 0)
self.assertDictEqual(
log.serialized_data["fields"],
{
"label": "test label",
"timestamp": "2022-03-01T12:00:00Z",
"date": "2022-04-05",
"code": "e82d5e53-ca80-4037-af55-b90752326460",
"nullable": 4,
"nested": {"foo": 10, "bar": False},
"mask_me": None,
},
)
def test_serialize_mask_fields(self):
with freezegun.freeze_time(self.test_date):
instance = SerializeThisModel.objects.create(
label="test label",
nullable=4,
timestamp=self.test_date,
nested={"foo": 10, "bar": False},
mask_me="confidential",
)
log = instance.history.first()
self.assertTrue(isinstance(log, LogEntry))
self.assertEqual(log.action, 0)
self.assertDictEqual(
log.serialized_data["fields"],
{
"label": "test label",
"timestamp": self.test_date_string,
"nullable": 4,
"nested": {"foo": 10, "bar": False},
"mask_me": "******ential",
"date": None,
"code": None,
},
)
def test_serialize_only_auditlog_fields(self):
with freezegun.freeze_time(self.test_date):
instance = SerializeOnlySomeOfThisModel.objects.create(
this="this should be there", not_this="leave this bit out"
)
log = instance.history.first()
self.assertTrue(isinstance(log, LogEntry))
self.assertEqual(log.action, 0)
self.assertDictEqual(
log.serialized_data["fields"], {"this": "this should be there"}
)
self.assertDictEqual(
log.changes_dict,
{"this": ["None", "this should be there"], "id": ["None", "1"]},
)
def test_serialize_related(self):
with freezegun.freeze_time(self.test_date):
serialize_this = SerializeThisModel.objects.create(
label="test label",
nested={"foo": "bar"},
timestamp=self.test_date,
)
instance = SerializePrimaryKeyRelatedModel.objects.create(
serialize_this=serialize_this,
subheading="use a primary key for this serialization, please.",
value=10,
)
log = instance.history.first()
self.assertTrue(isinstance(log, LogEntry))
self.assertEqual(log.action, 0)
self.assertDictEqual(
log.serialized_data["fields"],
{
"serialize_this": serialize_this.id,
"subheading": "use a primary key for this serialization, please.",
"value": 10,
},
)
def test_serialize_related_with_kwargs(self):
with freezegun.freeze_time(self.test_date):
serialize_this = SerializeThisModel.objects.create(
label="test label",
nested={"foo": "bar"},
timestamp=self.test_date,
)
instance = SerializeNaturalKeyRelatedModel.objects.create(
serialize_this=serialize_this,
subheading="use a natural key for this serialization, please.",
value=11,
)
log = instance.history.first()
self.assertTrue(isinstance(log, LogEntry))
self.assertEqual(log.action, 0)
self.assertDictEqual(
log.serialized_data["fields"],
{
"serialize_this": "test label",
"subheading": "use a natural key for this serialization, please.",
"value": 11,
},
)
def test_f_expressions(self):
serialize_this = SerializeThisModel.objects.create(
label="test label",
nested={"foo": "bar"},
timestamp=self.test_date,
nullable=1,
)
serialize_this.nullable = models.F("nullable") + 1
serialize_this.save()
log = serialize_this.history.first()
self.assertTrue(isinstance(log, LogEntry))
self.assertEqual(log.action, 1)
self.assertEqual(
log.serialized_data["fields"]["nullable"],
"F(nullable) + Value(1)",
)
class TestAccessLog(TestCase):
def setUp(self):
self.user = User.objects.create_user(username="test_user", is_active=True)
self.obj = SimpleModel.objects.create(text="For admin logentry test")
def test_access_log(self):
self.client.force_login(self.user)
content_type = ContentType.objects.get_for_model(self.obj.__class__)
# Check for log entries
qs = LogEntry.objects.filter(content_type=content_type, object_pk=self.obj.pk)
old_count = qs.count()
self.client.get(reverse("simplemodel-detail", args=[self.obj.pk]))
new_count = qs.count()
self.assertEqual(new_count, old_count + 1)
log_entry = qs.latest()
self.assertEqual(int(log_entry.object_pk), self.obj.pk)
self.assertEqual(log_entry.actor, self.user)
self.assertEqual(log_entry.content_type, content_type)
self.assertEqual(
log_entry.action, LogEntry.Action.ACCESS, msg="Action is 'ACCESS'"
)
self.assertIsNone(log_entry.changes)
self.assertEqual(log_entry.changes_dict, {})
class SignalTests(TestCase):
def setUp(self):
self.obj = SimpleModel.objects.create(text="I am not difficult.")
self.my_pre_log_data = {
"is_called": False,
"my_sender": None,
"my_instance": None,
"my_action": None,
}
self.my_post_log_data = {
"is_called": False,
"my_sender": None,
"my_instance": None,
"my_action": None,
"my_error": None,
"my_log_entry": None,
}
def assertSignals(self, action):
self.assertTrue(
self.my_pre_log_data["is_called"], "pre_log hook receiver not called"
)
self.assertIs(self.my_pre_log_data["my_sender"], self.obj.__class__)
self.assertIs(self.my_pre_log_data["my_instance"], self.obj)
self.assertEqual(self.my_pre_log_data["my_action"], action)
self.assertTrue(
self.my_post_log_data["is_called"], "post_log hook receiver not called"
)
self.assertIs(self.my_post_log_data["my_sender"], self.obj.__class__)
self.assertIs(self.my_post_log_data["my_instance"], self.obj)
self.assertEqual(self.my_post_log_data["my_action"], action)
self.assertIsNone(self.my_post_log_data["my_error"])
self.assertIsNotNone(self.my_post_log_data["my_log_entry"])
def test_custom_signals(self):
my_ret_val = random.randint(0, 10000)
my_other_ret_val = random.randint(0, 10000)
def pre_log_receiver(sender, instance, action, **_kwargs):
self.my_pre_log_data["is_called"] = True
self.my_pre_log_data["my_sender"] = sender
self.my_pre_log_data["my_instance"] = instance
self.my_pre_log_data["my_action"] = action
return my_ret_val
def pre_log_receiver_extra(*_args, **_kwargs):
return my_other_ret_val
def post_log_receiver(
sender, instance, action, error, log_entry, pre_log_results, **_kwargs
):
self.my_post_log_data["is_called"] = True
self.my_post_log_data["my_sender"] = sender
self.my_post_log_data["my_instance"] = instance
self.my_post_log_data["my_action"] = action
self.my_post_log_data["my_error"] = error
self.my_post_log_data["my_log_entry"] = log_entry
self.assertEqual(len(pre_log_results), 2)
found_first_result = False
found_second_result = False
for pre_log_fn, pre_log_result in pre_log_results:
if pre_log_fn is pre_log_receiver and pre_log_result == my_ret_val:
found_first_result = True
for pre_log_fn, pre_log_result in pre_log_results:
if (
pre_log_fn is pre_log_receiver_extra
and pre_log_result == my_other_ret_val
):
found_second_result = True
self.assertTrue(found_first_result)
self.assertTrue(found_second_result)
return my_ret_val
pre_log.connect(pre_log_receiver)
pre_log.connect(pre_log_receiver_extra)
post_log.connect(post_log_receiver)
self.obj = SimpleModel.objects.create(text="I am not difficult.")
self.assertSignals(LogEntry.Action.CREATE)
def test_disabled_logging(self):
log_count = LogEntry.objects.count()
def pre_log_receiver(sender, instance, action, **_kwargs):
return True
def pre_log_receiver_extra(*_args, **_kwargs):
pass
def pre_log_receiver_disable(*_args, **_kwargs):
return False
pre_log.connect(pre_log_receiver)
pre_log.connect(pre_log_receiver_extra)
self.obj = SimpleModel.objects.create(text="I am not difficult.")
self.assertEqual(LogEntry.objects.count(), log_count + 1)
log_count = LogEntry.objects.count()
pre_log.connect(pre_log_receiver_disable)
self.obj = SimpleModel.objects.create(text="I am not difficult.")
self.assertEqual(LogEntry.objects.count(), log_count)
def test_custom_signals_update(self):
def pre_log_receiver(sender, instance, action, **_kwargs):
self.my_pre_log_data["is_called"] = True
self.my_pre_log_data["my_sender"] = sender
self.my_pre_log_data["my_instance"] = instance
self.my_pre_log_data["my_action"] = action
def post_log_receiver(sender, instance, action, error, log_entry, **_kwargs):
self.my_post_log_data["is_called"] = True
self.my_post_log_data["my_sender"] = sender
self.my_post_log_data["my_instance"] = instance
self.my_post_log_data["my_action"] = action
self.my_post_log_data["my_error"] = error
self.my_post_log_data["my_log_entry"] = log_entry
pre_log.connect(pre_log_receiver)
post_log.connect(post_log_receiver)
self.obj.text = "Changed Text"
self.obj.save()
self.assertSignals(LogEntry.Action.UPDATE)
def test_custom_signals_delete(self):
def pre_log_receiver(sender, instance, action, **_kwargs):
self.my_pre_log_data["is_called"] = True
self.my_pre_log_data["my_sender"] = sender
self.my_pre_log_data["my_instance"] = instance
self.my_pre_log_data["my_action"] = action
def post_log_receiver(sender, instance, action, error, log_entry, **_kwargs):
self.my_post_log_data["is_called"] = True
self.my_post_log_data["my_sender"] = sender
self.my_post_log_data["my_instance"] = instance
self.my_post_log_data["my_action"] = action
self.my_post_log_data["my_error"] = error
self.my_post_log_data["my_log_entry"] = log_entry
pre_log.connect(pre_log_receiver)
post_log.connect(post_log_receiver)
self.obj.delete()
self.assertSignals(LogEntry.Action.DELETE)
@patch("auditlog.receivers.LogEntry.objects")
def test_signals_errors(self, log_entry_objects_mock):
class CustomSignalError(BaseException):
pass
def post_log_receiver(error, **_kwargs):
self.my_post_log_data["my_error"] = error
post_log.connect(post_log_receiver)
# create
error_create = CustomSignalError(LogEntry.Action.CREATE)
log_entry_objects_mock.log_create.side_effect = error_create
with self.assertRaises(CustomSignalError):
SimpleModel.objects.create(text="I am not difficult.")
self.assertEqual(self.my_post_log_data["my_error"], error_create)
# update
error_update = CustomSignalError(LogEntry.Action.UPDATE)
log_entry_objects_mock.log_create.side_effect = error_update
with self.assertRaises(CustomSignalError):
obj = SimpleModel.objects.get(pk=self.obj.pk)
obj.text = "updating"
obj.save()
self.assertEqual(self.my_post_log_data["my_error"], error_update)
# delete
error_delete = CustomSignalError(LogEntry.Action.DELETE)
log_entry_objects_mock.log_create.side_effect = error_delete
with self.assertRaises(CustomSignalError):
obj = SimpleModel.objects.get(pk=self.obj.pk)
obj.delete()
self.assertEqual(self.my_post_log_data["my_error"], error_delete)
@override_settings(AUDITLOG_DISABLE_ON_RAW_SAVE=True)
class DisableTest(TestCase):
"""
All the other tests check logging, so this only needs to test disabled logging.
"""
def test_create(self):
# Mimic the way imports create objects
inst = SimpleModel(
text="I am a bit more difficult.",
boolean=False,
datetime=django_timezone.now(),
)
SimpleModel.save_base(inst, raw=True)
self.assertEqual(0, LogEntry.objects.get_for_object(inst).count())
def test_create_with_context_manager(self):
with disable_auditlog():
inst = SimpleModel.objects.create(text="I am a bit more difficult.")
self.assertEqual(0, LogEntry.objects.get_for_object(inst).count())
def test_update(self):
inst = SimpleModel(
text="I am a bit more difficult.",
boolean=False,
datetime=django_timezone.now(),
)
SimpleModel.save_base(inst, raw=True)
inst.text = "I feel refreshed"
inst.save_base(raw=True)
self.assertEqual(0, LogEntry.objects.get_for_object(inst).count())
def test_update_with_context_manager(self):
inst = SimpleModel(
text="I am a bit more difficult.",
boolean=False,
datetime=django_timezone.now(),
)
SimpleModel.save_base(inst, raw=True)
with disable_auditlog():
inst.text = "I feel refreshed"
inst.save()
self.assertEqual(0, LogEntry.objects.get_for_object(inst).count())
def test_m2m(self):
"""
Create m2m from fixture and check that nothing was logged.
This only works with context manager
"""
with disable_auditlog():
management.call_command(
"loaddata", "test_app/fixtures/m2m_test_fixture.json", verbosity=0
)
recursive = ManyRelatedModel.objects.get(pk=1)
self.assertEqual(0, LogEntry.objects.get_for_object(recursive).count())
related = ManyRelatedOtherModel.objects.get(pk=1)
self.assertEqual(0, LogEntry.objects.get_for_object(related).count())
class MissingModelTest(TestCase):
def setUp(self):
# Create a log entry, then unregister the model
self.obj = SimpleModel.objects.create(text="I am old.")
auditlog.unregister(SimpleModel)
def tearDown(self):
# Re-register the model for other tests
auditlog.register(SimpleModel)
def test_get_changes_for_missing_model(self):
history = self.obj.history.latest()
self.assertEqual(history.changes_dict["text"][1], self.obj.text)
self.assertEqual(history.changes_display_dict["text"][1], self.obj.text)
class ModelManagerTest(TestCase):
"""
This does not directly assert the configured manager, but its behaviour.
The "secret" object should not be accessible, as the queryset is overridden.
"""
def setUp(self):
self.secret = SwappedManagerModel.objects.create(is_secret=True, name="Secret")
self.public = SwappedManagerModel.objects.create(is_secret=False, name="Public")
def test_update_secret(self):
self.secret.name = "Updated"
self.secret.save()
log = LogEntry.objects.get_for_object(self.secret).first()
self.assertEqual(log.action, LogEntry.Action.UPDATE)
self.assertEqual(log.changes_dict["name"], ["None", "Updated"])
def test_update_public(self):
self.public.name = "Updated"
self.public.save()
log = LogEntry.objects.get_for_object(self.public).first()
self.assertEqual(log.action, LogEntry.Action.UPDATE)
self.assertEqual(log.changes_dict["name"], ["Public", "Updated"])
django-auditlog-3.1.2/docs/ 0000775 0000000 0000000 00000000000 15003135774 0015522 5 ustar 00root root 0000000 0000000 django-auditlog-3.1.2/docs/Makefile 0000664 0000000 0000000 00000001177 15003135774 0017170 0 ustar 00root root 0000000 0000000 # Minimal makefile for Sphinx documentation
#
# 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:
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
.PHONY: help Makefile
# 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)
django-auditlog-3.1.2/docs/make.bat 0000664 0000000 0000000 00000001441 15003135774 0017127 0 ustar 00root root 0000000 0000000 @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
django-auditlog-3.1.2/docs/requirements.txt 0000664 0000000 0000000 00000000115 15003135774 0021003 0 ustar 00root root 0000000 0000000 # Docs requirements
django>=4.2,<4.3
sphinx
sphinx_rtd_theme
psycopg2-binary
django-auditlog-3.1.2/docs/source/ 0000775 0000000 0000000 00000000000 15003135774 0017022 5 ustar 00root root 0000000 0000000 django-auditlog-3.1.2/docs/source/conf.py 0000664 0000000 0000000 00000004624 15003135774 0020327 0 ustar 00root root 0000000 0000000 # Configuration file for the Sphinx documentation builder.
#
# 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 os
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.
# Add sources folder
sys.path.insert(0, os.path.abspath("../../auditlog_tests"))
# 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",
]
# 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"]
# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
# This pattern also affects html_static_path and html_extra_path.
exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
# -- 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"
# 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']
django-auditlog-3.1.2/docs/source/index.rst 0000664 0000000 0000000 00000002612 15003135774 0020664 0 ustar 00root root 0000000 0000000 django-auditlog documentation
=============================
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. However, Auditlog is much more flexible than the
log from Django's admin app (:py:mod:`django.contrib.admin`). Also, Auditlog saves a summary of the changes in JSON
format, so changes can be tracked easily.
Contents
--------
.. toctree::
:maxdepth: 2
installation
usage
upgrade
internals
Contribute to Auditlog
----------------------
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/jazzband/django-auditlog
| Issues: https://github.com/jazzband/django-auditlog/issues
django-auditlog-3.1.2/docs/source/installation.rst 0000664 0000000 0000000 00000002313 15003135774 0022254 0 ustar 00root root 0000000 0000000 Installation
============
Installing Auditlog is simple and straightforward. First of all, you need a copy of Auditlog on your system. The easiest
way to do this is by using the Python Package Index (PyPI). Simply run the following command:
``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/jazzband/django-auditlog/.
**Requirements**
- Python 3.9 or higher
- Django 4.2, 5.0 and 5.1
Auditlog is currently tested with Python 3.9+ and Django 4.2, 5.0 and 5.1. The latest test report can be found
at https://github.com/jazzband/django-auditlog/actions.
Adding Auditlog to your Django application
------------------------------------------
To use Auditlog in your application, just add ``'auditlog'`` to your project's ``INSTALLED_APPS`` setting and run
``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`` setting. Please check :doc:`usage` for more
information.
django-auditlog-3.1.2/docs/source/internals.rst 0000664 0000000 0000000 00000002325 15003135774 0021555 0 ustar 00root root 0000000 0000000 Internals
=========
You might be interested in the way things work on the inside of Auditlog. This section covers the internal APIs of
Auditlog which is very useful when you are looking for more advanced ways to use the application or if you like to
contribute to the project.
The documentation below is automatically generated from the source code.
Models and fields
-----------------
.. automodule:: auditlog.models
:members: LogEntry, LogEntryManager, AuditlogHistoryField
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
-------------------
.. automodule:: auditlog.diff
:members:
Registry
--------
.. automodule:: auditlog.registry
:members:
django-auditlog-3.1.2/docs/source/upgrade.rst 0000664 0000000 0000000 00000002261 15003135774 0021204 0 ustar 00root root 0000000 0000000 Upgrading to version 3
======================
Version 3.0.0 introduces breaking changes. Please review the migration guide below before upgrading.
If you're new to django-auditlog, you can ignore this part.
The major change in the version is that we're finally storing changes as json instead of json-text.
To convert the existing records, this version has a database migration that does just that.
However, this migration will take a long time if you have a huge amount of records,
causing your database and application to be out of sync until the migration is complete.
To avoid this, follow these steps:
1. Before upgrading the package, add these two variables to ``settings.py``:
* ``AUDITLOG_TWO_STEP_MIGRATION = True``
* ``AUDITLOG_USE_TEXT_CHANGES_IF_JSON_IS_NOT_PRESENT = True``
2. Upgrade the package. Your app will now start storing new records as JSON, but the old records will accessible via ``LogEntry.changes_text``.
3. Use the newly added ``auditlogmigratejson`` command to migrate your records. Run ``django-admin auditlogmigratejson --help`` to get more information.
4. Once all records are migrated, remove the variables listed above, or set their values to ``False``.
django-auditlog-3.1.2/docs/source/usage.rst 0000664 0000000 0000000 00000045004 15003135774 0020663 0 ustar 00root root 0000000 0000000 Usage
=====
.. py:currentmodule:: auditlog.models
Manually logging changes
------------------------
Auditlog log entries are simple :py:class:`LogEntry` model instances. This makes creating a new log entry very easy. For
even more convenience, :py:class:`LogEntryManager` provides a number of methods which take some work out of your hands.
See :doc:`internals` for all details.
.. _Automatically logging changes:
Automatically logging changes
-----------------------------
Auditlog can automatically log changes to objects for you. This functionality is based on Django's signals, but linking
your models to Auditlog is even easier than using signals.
Registering your model for logging can be done with a single line of code, as the following example illustrates:
.. code-block:: python
from django.db import models
from auditlog.registry import auditlog
class MyModel(models.Model):
pass
# Model definition goes here
auditlog.register(MyModel)
It is recommended to place the register code (``auditlog.register(MyModel)``) at the bottom of your ``models.py`` file.
This ensures that every time your model is imported it will also be registered to log changes. Auditlog makes sure that
each model is only registered once, otherwise duplicate log entries would occur.
**Logging access**
By default, Auditlog will only log changes to your model instances. If you want to log access to your model instances as well, Auditlog provides a mixin class for that purpose. Simply add the :py:class:`auditlog.mixins.LogAccessMixin` to your class based view and Auditlog will log access to your model instances. The mixin expects your view to have a ``get_object`` method that returns the model instance for which access shall be logged - this is usually the case for DetailViews and UpdateViews.
A DetailView utilizing the LogAccessMixin could look like the following example:
.. code-block:: python
from django.views.generic import DetailView
from auditlog.mixins import LogAccessMixin
class MyModelDetailView(LogAccessMixin, DetailView):
model = MyModel
# View code goes here
You can also add log-access to function base views, as the following example illustrates:
.. code-block:: python
from auditlog.signals import accessed
def profile_view(request, pk):
## get the object you want to log access
user = User.objects.get(pk=pk)
## log access
accessed.send(user.__class__, instance=user)
# View code goes here
...
**Excluding fields**
Fields that are excluded will not trigger saving a new log entry and will not show up in the recorded changes.
To exclude specific fields from the log you can pass ``include_fields`` resp. ``exclude_fields`` to the ``register``
method. If ``exclude_fields`` is specified the fields with the given names will not be included in the generated log
entries. If ``include_fields`` is specified only the fields with the given names will be included in the generated log
entries. Explicitly excluding fields through ``exclude_fields`` takes precedence over specifying which fields to
include.
For example, to exclude the field ``last_updated``, use::
auditlog.register(MyModel, exclude_fields=['last_updated'])
.. versionadded:: 0.3.0
Excluding fields
**Mapping fields**
If you have field names on your models that aren't intuitive or user friendly you can include a dictionary of field mappings
during the `register()` call.
.. code-block:: python
from django.db import models
from auditlog.models import AuditlogHistoryField
from auditlog.registry import auditlog
class MyModel(models.Model):
sku = models.CharField(max_length=20)
version = models.CharField(max_length=5)
product = models.CharField(max_length=50, verbose_name='Product Name')
history = AuditlogHistoryField()
auditlog.register(MyModel, mapping_fields={'sku': 'Product No.', 'version': 'Product Revision'})
.. code-block:: python
log = MyModel.objects.first().history.latest()
log.changes_display_dict
// retrieves changes with keys Product No. Product Revision, and Product Name
// If you don't map a field it will fall back on the verbose_name
.. versionadded:: 0.5.0
You do not need to map all the fields of the model, any fields not mapped will fall back on their ``verbose_name``. Django provides a default ``verbose_name`` which is a "munged camel case version" so ``product_name`` would become ``Product Name`` by default.
**Masking fields**
Fields that contain sensitive info and we want keep track of field change but not to contain the exact change.
To mask specific fields from the log you can pass ``mask_fields`` to the ``register``
method. If ``mask_fields`` is specified, the first half value of the fields is masked using ``*``.
For example, to mask the field ``address``, use::
auditlog.register(MyModel, mask_fields=['address'])
.. versionadded:: 2.0.0
Masking fields
**Many-to-many fields**
Changes to many-to-many fields are not tracked by default. If you want to enable tracking of a many-to-many field on a model, pass ``m2m_fields`` to the ``register`` method:
.. code-block:: python
auditlog.register(MyModel, m2m_fields={"tags", "contacts"})
This functionality is based on the ``m2m_changed`` signal sent by the ``through`` model of the relationship.
Note that when the user changes multiple many-to-many fields on the same object through the admin, both adding and removing some objects from each, this code will generate multiple log entries: each log entry will represent a single operation (add or delete) of a single field, e.g. if you both add and delete values from 2 fields on the same form in the same request, you'll get 4 log entries.
.. versionadded:: 2.1.0
**Serialized Data**
The state of an object following a change action may be optionally serialized and persisted in the ``LogEntry.serialized_data`` JSONField. To enable this feature for a registered model, add ``serialize_data=True`` to the kwargs on the ``auditlog.register(...)`` method. Object serialization will not occur unless this kwarg is set.
.. code-block:: python
auditlog.register(MyModel, serialize_data=True)
Objects are serialized using the Django core serializer. Keyword arguments may be passed to the serializer through ``serialize_kwargs``.
.. code-block:: python
auditlog.register(
MyModel,
serialize_data=True,
serialize_kwargs={"fields": ["foo", "bar", "biz", "baz"]}
)
Note that all fields on the object will be serialized unless restricted with one or more configurations. The `serialize_kwargs` option contains a `fields` argument and this may be given an inclusive list of field names to serialize (as shown above). Alternatively, one may set ``serialize_auditlog_fields_only`` to ``True`` when registering a model with ``exclude_fields`` and ``include_fields`` set (as shown below). This will cause the data persisted in ``LogEntry.serialized_data`` to be limited to the same scope that is persisted within the ``LogEntry.changes`` field.
.. code-block:: python
auditlog.register(
MyModel,
exclude_fields=["ssn", "confidential"]
serialize_data=True,
serialize_auditlog_fields_only=True
)
Field masking is supported in object serialization. Any value belonging to a field whose name is found in the ``mask_fields`` list will be masked in the serialized object data. Masked values are obfuscated with asterisks in the same way as they are in the ``LogEntry.changes`` field.
Correlation ID
--------------
You can store a correlation ID (cid) in the log entries by:
1. Reading from a request header (specified by `AUDITLOG_CID_HEADER`)
2. Using a custom cid getter (specified by `AUDITLOG_CID_GETTER`)
Using the custom getter is helpful for integrating with a third-party cid package
such as `django-cid `_.
Settings
--------
**AUDITLOG_INCLUDE_ALL_MODELS**
You can use this setting to register all your models:
.. code-block:: python
AUDITLOG_INCLUDE_ALL_MODELS=True
.. versionadded:: 2.1.0
**AUDITLOG_EXCLUDE_TRACKING_FIELDS**
You can use this setting to exclude named fields from ALL models.
This is useful when lots of models share similar fields like
```created``` and ```modified``` and you want those excluded from
logging.
It will be considered when ``AUDITLOG_INCLUDE_ALL_MODELS`` is `True`.
.. code-block:: python
AUDITLOG_EXCLUDE_TRACKING_FIELDS = (
"created",
"modified"
)
.. versionadded:: 3.0.0
**AUDITLOG_EXCLUDE_TRACKING_FIELDS**
When using "AuditlogMiddleware",
the IP address is logged by default, you can use this setting
to exclude the IP address from logging.
It will be considered when ``AUDITLOG_DISABLE_REMOTE_ADDR`` is `True`.
.. code-block:: python
AUDITLOG_DISABLE_REMOTE_ADDR = True
.. versionadded:: 3.0.0
**AUDITLOG_MASK_TRACKING_FIELDS**
You can use this setting to mask specific field values in all tracked models
while still logging changes. This is useful when models contain sensitive fields
like `password`, `api_key`, or `secret_token`` that should not be logged
in plain text but need to be auditable.
When a masked field changes, its value will be replaced with a masked
representation (e.g., `****`) in the audit log instead of storing the actual value.
This setting will be applied only when `AUDITLOG_INCLUDE_ALL_MODELS`` is `True`.
.. code-block:: python
AUDITLOG_MASK_TRACKING_FIELDS = (
"password",
"api_key",
"secret_token"
)
.. versionadded:: 3.1.0
**AUDITLOG_EXCLUDE_TRACKING_MODELS**
You can use this setting to exclude models in registration process.
It will be considered when ``AUDITLOG_INCLUDE_ALL_MODELS`` is `True`.
.. code-block:: python
AUDITLOG_EXCLUDE_TRACKING_MODELS = (
"",
"."
)
.. versionadded:: 2.1.0
**AUDITLOG_INCLUDE_TRACKING_MODELS**
You can use this setting to configure your models registration and other behaviours.
It must be a list or tuple. Each item in this setting can be a:
* ``str``: To register a model.
* ``dict``: To register a model and define its logging behaviour. e.g. include_fields, exclude_fields.
.. code-block:: python
AUDITLOG_INCLUDE_TRACKING_MODELS = (
".",
{
"model": ".",
"include_fields": ["field1", "field2"],
"exclude_fields": ["field3", "field4"],
"mapping_fields": {
"field1": "FIELD",
},
"mask_fields": ["field5", "field6"],
"m2m_fields": ["field7", "field8"],
"serialize_data": True,
"serialize_auditlog_fields_only": False,
"serialize_kwargs": {"fields": ["foo", "bar", "biz", "baz"]},
},
".",
)
.. versionadded:: 2.1.0
**AUDITLOG_DISABLE_ON_RAW_SAVE**
Disables logging during raw save. (I.e. for instance using loaddata)
.. note::
M2M operations will still be logged, since they're never considered `raw`. To disable them
you must remove their setting or use the `disable_auditlog` context manager.
.. versionadded:: 2.2.0
**AUDITLOG_CID_HEADER**
The request header containing the Correlation ID value to use in all log entries created as a result of the request.
The value can of in the format `HTTP_MY_HEADER` or `my-header`.
.. versionadded:: 3.0.0
**AUDITLOG_CID_GETTER**
The function to use to retrieve the Correlation ID. The value can be a callable or a string import path.
If the value is `None`, the default getter will be used.
.. versionadded:: 3.0.0
**AUDITLOG_CHANGE_DISPLAY_TRUNCATE_LENGTH**
This configuration variable defines the truncation behavior for strings in `changes_display_dict`, with a default value of `140` characters.
0: The entire string is truncated, resulting in an empty output.
Positive values (e.g., 5): Truncates the string, keeping only the specified number of characters followed by an ellipsis (...) after the limit.
Negative values: No truncation occurs, and the full string is displayed.
.. versionadded:: 3.1.0
Actors
------
Middleware
**********
When using automatic logging, the actor is empty by default. However, auditlog can set the actor from the current
request automatically. This does not need any custom code, adding a middleware class is enough. When an actor is logged
the remote address of that actor will be logged as well.
To enable the automatic logging of the actors, simply add the following to your ``MIDDLEWARE`` setting in your
project's configuration file::
MIDDLEWARE = (
# Request altering middleware, e.g., Django's default middleware classes
'auditlog.middleware.AuditlogMiddleware',
# Other middleware
)
It is recommended to keep all middleware that alters the request loaded before Auditlog's middleware.
.. warning::
Please keep in mind that every object change in a request that gets logged automatically will have the current request's
user as actor. To only have some object changes to be logged with the current request's user as actor manual logging is
required.
Context managers
----------------
Set actor
*********
To enable the automatic logging of the actors outside of request context (e.g. in a Celery task), you can use a context
manager::
from auditlog.context import set_actor
def do_stuff(actor_id: int):
actor = get_user(actor_id)
with set_actor(actor):
# if your code here leads to creation of LogEntry instances, these will have the actor set
...
.. versionadded:: 2.1.0
Disable auditlog
****************
Disable auditlog temporary, for instance if you need to install a large fixture on a live system or cleanup
corrupt data::
from auditlog.context import disable_auditlog
with disable_auditlog():
# Do things silently here
...
.. versionadded:: 2.2.0
Object history
--------------
Auditlog ships with a custom field that enables you to easily get the log entries that are relevant to your object. This
functionality is built on Django's content types framework (:py:mod:`django.contrib.contenttypes`). Using this field in
your models is equally easy as any other field::
from django.db import models
from auditlog.models import AuditlogHistoryField
from auditlog.registry import auditlog
class MyModel(models.Model):
history = AuditlogHistoryField()
# Model definition goes here
auditlog.register(MyModel)
:py:class:`AuditlogHistoryField` accepts an optional :py:attr:`pk_indexable` parameter, which is either ``True`` or
``False``, this defaults to ``True``. If your model has a custom primary key that is not an integer value,
:py:attr:`pk_indexable` needs to be set to ``False``. Keep in mind that this might slow down queries.
The :py:class:`AuditlogHistoryField` provides easy access to :py:class:`LogEntry` instances related to the model instance. Here is an example of how to use it:
.. code-block:: html
Field |
From |
To |
{% for key, value in mymodel.history.latest.changes_dict.items %}
{{ key }} |
{{ value.0|default:"None" }} |
{{ value.1|default:"None" }} |
{% empty %}
No history for this item has been logged yet.
{% endfor %}
If you want to display the changes in a more human readable format use the :py:class:`LogEntry`'s :py:attr:`changes_display_dict` instead. The :py:attr:`changes_display_dict` will make a few cosmetic changes to the data.
- Mapping Fields property will be used to display field names, falling back on ``verbose_name`` if no mapping field is present
- Fields with a value whose length is greater than 140 will be truncated with an ellipsis appended
- Date, Time, and DateTime fields will follow ``L10N`` formatting. If ``USE_L10N=False`` in your settings it will fall back on the settings defaults defined for ``DATE_FORMAT``, ``TIME_FORMAT``, and ``DATETIME_FORMAT``
- Fields with ``choices`` will be translated into their human readable form, this feature also supports choices defined on ``django-multiselectfield`` and Postgres's native ``ArrayField``
Check out the internals for the full list of attributes you can use to get associated :py:class:`LogEntry` instances.
Many-to-many relationships
--------------------------
.. versionadded:: 0.3.0
.. note::
This section shows a workaround which can be used to track many-to-many relationships on older versions of django-auditlog. For versions 2.1.0 and onwards, please see the many-to-many fields section of :ref:`Automatically logging changes`.
**Do not rely on the workaround here to be stable across releases.**
By default, many-to-many relationships are not tracked by Auditlog.
The history for a many-to-many relationship without an explicit 'through' model can be recorded by registering this
model as follows::
auditlog.register(MyModel.related.through)
The log entries for all instances of the 'through' model that are related to a ``MyModel`` instance can be retrieved
with the :py:meth:`LogEntryManager.get_for_objects` method. The resulting QuerySet can be combined with any other
queryset of :py:class:`LogEntry` instances. This way it is possible to get a list of all changes on an object and its
related objects::
obj = MyModel.objects.first()
rel_history = LogEntry.objects.get_for_objects(obj.related.all())
full_history = (obj.history.all() | rel_history.all()).order_by('-timestamp')
Management commands
-------------------
.. versionadded:: 0.4.0
Auditlog provides the ``auditlogflush`` management command to clear all log entries from the database.
By default, the command asks for confirmation. It is possible to run the command with the ``-y`` or ``--yes`` flag to skip
confirmation and immediately delete all entries.
You may also specify a date using the ``-b`` or ``--before-date`` option in ISO 8601 format (YYYY-mm-dd) to delete all
log entries prior to a given date. This may be used to implement time based retention windows.
.. versionadded:: 2.1.0
.. warning::
Using the ``auditlogflush`` command deletes log entries permanently and irreversibly from the database.
Django Admin integration
------------------------
.. versionadded:: 0.4.1
When ``auditlog`` is added to your ``INSTALLED_APPS`` setting a customized admin class is active providing an enhanced
Django Admin interface for log entries.
django-auditlog-3.1.2/pyproject.toml 0000664 0000000 0000000 00000000107 15003135774 0017504 0 ustar 00root root 0000000 0000000 [tool.black]
target-version = ["py39"]
[tool.isort]
profile = "black"
django-auditlog-3.1.2/setup.py 0000664 0000000 0000000 00000003040 15003135774 0016301 0 ustar 00root root 0000000 0000000 import os
from setuptools import setup
# Readme as long description
with open(os.path.join(os.path.dirname(__file__), "README.md")) as readme_file:
long_description = readme_file.read()
setup(
name="django-auditlog",
use_scm_version={"version_scheme": "post-release"},
setup_requires=["setuptools_scm"],
packages=[
"auditlog",
"auditlog.migrations",
"auditlog.management",
"auditlog.management.commands",
],
url="https://github.com/jazzband/django-auditlog",
project_urls={
"Documentation": "https://django-auditlog.readthedocs.io",
"Source": "https://github.com/jazzband/django-auditlog",
"Tracker": "https://github.com/jazzband/django-auditlog/issues",
},
license="MIT",
author="Jan-Jelle Kester",
description="Audit log app for Django",
long_description=long_description,
long_description_content_type="text/markdown",
python_requires=">=3.9",
install_requires=["Django>=4.2", "python-dateutil>=2.7.0"],
zip_safe=False,
classifiers=[
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Framework :: Django",
"Framework :: Django :: 4.2",
"Framework :: Django :: 5.0",
"Framework :: Django :: 5.1",
"License :: OSI Approved :: MIT License",
],
)
django-auditlog-3.1.2/tox.ini 0000664 0000000 0000000 00000002537 15003135774 0016114 0 ustar 00root root 0000000 0000000 [tox]
envlist =
{py39,py310,py311}-django42
{py310,py311,py312}-django50
{py310,py311,py312,py313}-django51
{py312,py313}-djangomain
py39-docs
py39-lint
py39-checkmigrations
[testenv]
setenv =
COVERAGE_FILE={toxworkdir}/.coverage.{envname}
changedir = auditlog_tests
commands =
coverage run --source auditlog ./manage.py test
coverage xml
deps =
django42: Django>=4.2,<4.3
django50: Django>=5.0,<5.1
django51: Django>=5.1,<5.2
djangomain: https://github.com/django/django/archive/main.tar.gz
# Test requirements
coverage
codecov
freezegun
psycopg2-binary
passenv=
TEST_DB_HOST
TEST_DB_USER
TEST_DB_PASS
TEST_DB_NAME
TEST_DB_PORT
basepython =
py313: python3.13
py312: python3.12
py311: python3.11
py310: python3.10
py39: python3.9
[testenv:py39-docs]
changedir = docs/source
deps = -rdocs/requirements.txt
commands = sphinx-build -W -b html -d {envtmpdir}/doctrees . {envtmpdir}/html
[testenv:py39-lint]
deps = pre-commit
commands =
pre-commit run --all-files
[testenv:py39-checkmigrations]
description = Check for missing migrations
changedir = auditlog_tests
deps =
Django>=4.2
psycopg2
commands =
python manage.py makemigrations --check --dry-run
[gh-actions]
python =
3.9: py39
3.10: py310
3.11: py311
3.12: py312
3.13: py313