Compare commits

..

324 commits

Author SHA1 Message Date
pre-commit-ci[bot]
0e3a2ec1a7
[pre-commit.ci] pre-commit autoupdate (#808)
Some checks failed
Test / SQLite • Python 3.10 (push) Has been cancelled
Test / SQLite • Python 3.11 (push) Has been cancelled
Test / SQLite • Python 3.12 (push) Has been cancelled
Test / SQLite • Python 3.13 (push) Has been cancelled
Test / PostgreSQL • Python 3.10 (push) Has been cancelled
Test / PostgreSQL • Python 3.11 (push) Has been cancelled
Test / PostgreSQL • Python 3.12 (push) Has been cancelled
Test / PostgreSQL • Python 3.13 (push) Has been cancelled
Test / MySQL • Python 3.10 (push) Has been cancelled
Test / MySQL • Python 3.11 (push) Has been cancelled
Test / MySQL • Python 3.12 (push) Has been cancelled
Test / MySQL • Python 3.13 (push) Has been cancelled
updates:
- [github.com/psf/black-pre-commit-mirror: 26.1.0 → 26.3.0](https://github.com/psf/black-pre-commit-mirror/compare/26.1.0...26.3.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-03-10 10:58:29 +01:00
pre-commit-ci[bot]
dfd5b79d2d
[pre-commit.ci] pre-commit autoupdate (#807)
Some checks failed
Test / SQLite • Python 3.10 (push) Has been cancelled
Test / SQLite • Python 3.11 (push) Has been cancelled
Test / SQLite • Python 3.12 (push) Has been cancelled
Test / SQLite • Python 3.13 (push) Has been cancelled
Test / PostgreSQL • Python 3.10 (push) Has been cancelled
Test / PostgreSQL • Python 3.11 (push) Has been cancelled
Test / PostgreSQL • Python 3.12 (push) Has been cancelled
Test / PostgreSQL • Python 3.13 (push) Has been cancelled
Test / MySQL • Python 3.10 (push) Has been cancelled
Test / MySQL • Python 3.11 (push) Has been cancelled
Test / MySQL • Python 3.12 (push) Has been cancelled
Test / MySQL • Python 3.13 (push) Has been cancelled
updates:
- [github.com/PyCQA/isort: 8.0.0 → 8.0.1](https://github.com/PyCQA/isort/compare/8.0.0...8.0.1)
- [github.com/adamchainz/django-upgrade: 1.29.1 → 1.30.0](https://github.com/adamchainz/django-upgrade/compare/1.29.1...1.30.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-03-04 13:06:54 +01:00
pre-commit-ci[bot]
4154560de3
[pre-commit.ci] pre-commit autoupdate (#806)
Some checks failed
Test / SQLite • Python 3.10 (push) Has been cancelled
Test / SQLite • Python 3.11 (push) Has been cancelled
Test / SQLite • Python 3.12 (push) Has been cancelled
Test / SQLite • Python 3.13 (push) Has been cancelled
Test / PostgreSQL • Python 3.10 (push) Has been cancelled
Test / PostgreSQL • Python 3.11 (push) Has been cancelled
Test / PostgreSQL • Python 3.12 (push) Has been cancelled
Test / PostgreSQL • Python 3.13 (push) Has been cancelled
Test / MySQL • Python 3.10 (push) Has been cancelled
Test / MySQL • Python 3.11 (push) Has been cancelled
Test / MySQL • Python 3.12 (push) Has been cancelled
Test / MySQL • Python 3.13 (push) Has been cancelled
updates:
- [github.com/PyCQA/isort: 7.0.0 → 8.0.0](https://github.com/PyCQA/isort/compare/7.0.0...8.0.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-02-24 11:56:32 +00:00
congusbongus
3f255a02d9
Fix typo in usage.rst regarding field exclusion (#801)
Some checks failed
Test / SQLite • Python 3.10 (push) Has been cancelled
Test / SQLite • Python 3.11 (push) Has been cancelled
Test / SQLite • Python 3.12 (push) Has been cancelled
Test / SQLite • Python 3.13 (push) Has been cancelled
Test / PostgreSQL • Python 3.10 (push) Has been cancelled
Test / PostgreSQL • Python 3.11 (push) Has been cancelled
Test / PostgreSQL • Python 3.12 (push) Has been cancelled
Test / PostgreSQL • Python 3.13 (push) Has been cancelled
Test / MySQL • Python 3.10 (push) Has been cancelled
Test / MySQL • Python 3.11 (push) Has been cancelled
Test / MySQL • Python 3.12 (push) Has been cancelled
Test / MySQL • Python 3.13 (push) Has been cancelled
2026-02-05 10:56:57 +01:00
marco-thirona
6d170da5fc
Add support for m2m changes in AbstractLogEntry.changes_str (#798)
Some checks failed
Test / SQLite • Python 3.10 (push) Has been cancelled
Test / SQLite • Python 3.11 (push) Has been cancelled
Test / SQLite • Python 3.12 (push) Has been cancelled
Test / SQLite • Python 3.13 (push) Has been cancelled
Test / PostgreSQL • Python 3.10 (push) Has been cancelled
Test / PostgreSQL • Python 3.11 (push) Has been cancelled
Test / PostgreSQL • Python 3.12 (push) Has been cancelled
Test / PostgreSQL • Python 3.13 (push) Has been cancelled
Test / MySQL • Python 3.10 (push) Has been cancelled
Test / MySQL • Python 3.11 (push) Has been cancelled
Test / MySQL • Python 3.12 (push) Has been cancelled
Test / MySQL • Python 3.13 (push) Has been cancelled
* Add test case to test log entry changes_str property for m2m changes.

* Add support for m2m field changes and generic changes in AbstractLogEntry.changes_str property.

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

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

* Add more test cases for changes_str.

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

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

* Add chengelog note

* Validate type and length of changes_dict values.

* Restructure change iterator.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-01-29 21:44:40 +01:00
pre-commit-ci[bot]
198c060c3b
[pre-commit.ci] pre-commit autoupdate (#799)
Some checks failed
Test / SQLite • Python 3.10 (push) Has been cancelled
Test / SQLite • Python 3.11 (push) Has been cancelled
Test / SQLite • Python 3.12 (push) Has been cancelled
Test / SQLite • Python 3.13 (push) Has been cancelled
Test / PostgreSQL • Python 3.10 (push) Has been cancelled
Test / PostgreSQL • Python 3.11 (push) Has been cancelled
Test / PostgreSQL • Python 3.12 (push) Has been cancelled
Test / PostgreSQL • Python 3.13 (push) Has been cancelled
Test / MySQL • Python 3.10 (push) Has been cancelled
Test / MySQL • Python 3.11 (push) Has been cancelled
Test / MySQL • Python 3.12 (push) Has been cancelled
Test / MySQL • Python 3.13 (push) Has been cancelled
* [pre-commit.ci] pre-commit autoupdate

updates:
- [github.com/psf/black-pre-commit-mirror: 25.12.0 → 26.1.0](https://github.com/psf/black-pre-commit-mirror/compare/25.12.0...26.1.0)

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

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

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-01-19 19:43:03 +01:00
pre-commit-ci[bot]
ede4d10164
[pre-commit.ci] pre-commit autoupdate (#790)
updates:
- [github.com/psf/black-pre-commit-mirror: 25.11.0 → 25.12.0](https://github.com/psf/black-pre-commit-mirror/compare/25.11.0...25.12.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-01-07 19:57:08 +01:00
Youngkwang Yang
aedb6ead39
Update CHANGELOG.md (#791) 2025-12-18 17:52:50 +09:00
dependabot[bot]
fb762a054f
Bump actions/cache from 4 to 5 in the github-actions group (#794)
Bumps the github-actions group with 1 update: [actions/cache](https://github.com/actions/cache).


Updates `actions/cache` from 4 to 5
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/cache
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-16 08:48:43 +01:00
James Gillard
dc636716b0
Fix AttributeError when AUDITLOG_LOGENTRY_MODEL is not set (#788) (#789)
Use getattr() with default value in get_logentry_model() to handle
cases where conf.py hasn't been imported yet due to import order.

Adds regression test to verify the fix.
2025-12-13 00:28:10 +09:00
Youngkwang Yang
66125030a8
Update CHANGELOG.md (#787) 2025-12-05 00:13:49 +09:00
Fabian Allendorf
eb9eefd76f
Add new setting to control FK change representation (#779)
* add new setting to activate string repr

* skip using foreign key to construct and display diff of foreign key fields

* concise name and documentation update

# Conflicts:
#	docs/source/usage.rst

* add test for enabled setting

* fix code block in docs

* fix version

* add warning to documentation
2025-12-02 16:49:16 +01:00
Lukas Graf
d02ed6b9e0
Make diffing more robust for polymorphic models (#784)
* Add failing test for diffing polymorphic model instances.

* Make diffing more robust for polymorphic models:

When working with polymorphic models, where a child model inherits from a
parent model, Django's pre_save signal may send model instances in a way
where the log_update() handler receives an instance of the child as the
`old` model, but an instance of the parent as the `new` model.

This leads to a `FieldDoesNotExist` error when a field that only exists on the
child was modified, and `get_field_value()` attempts look up that field on the
parent.

This change makes diffing polymorphic models more robust by considering this
case in `get_default_value()`. Changes to those child fields won't be tracked
in these cases, but at least `django-auditlog` won't prevent the model from
being saved.
2025-11-25 09:12:41 +01:00
pre-commit-ci[bot]
074e6aa145
[pre-commit.ci] pre-commit autoupdate (#782)
updates:
- [github.com/psf/black-pre-commit-mirror: 25.9.0 → 25.11.0](https://github.com/psf/black-pre-commit-mirror/compare/25.9.0...25.11.0)
- [github.com/asottile/pyupgrade: v3.21.0 → v3.21.2](https://github.com/asottile/pyupgrade/compare/v3.21.0...v3.21.2)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-11-24 19:19:51 +01:00
dependabot[bot]
03336f9ef2
Bump actions/checkout from 5 to 6 in the github-actions group (#783)
Bumps the github-actions group with 1 update: [actions/checkout](https://github.com/actions/checkout).


Updates `actions/checkout` from 5 to 6
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-24 13:56:49 +01:00
mostafaeftekharizadeh
7d13fd4ba8
Add CustomLogEntry model support and update tests: (#764)
* Add CustomLogEntry model support and update tests:

- Added support for CustomLogEntry data model to extend django-auditlog capabilities

- Updated existing test cases to align with new model structure and data handling logic

- Added new test cases to validate CustomLogEntry behavior, model registration, and signal handling

- Ensured backward compatibility with existing LogEntry model where applicable

* Update auditlog/__init__.py

Co-authored-by: Youngkwang Yang <me@youngkwang.dev>

* run only one custom model test matrix (#761)

---------

Co-authored-by: Youngkwang Yang <me@youngkwang.dev>
2025-11-19 09:46:43 +01:00
Hasan Ramezani
0e58a9d2d5
Update mysql version in test docker-compose (#777) 2025-10-31 14:53:16 +01:00
pre-commit-ci[bot]
8c9b7b4a6e
[pre-commit.ci] pre-commit autoupdate (#774)
updates:
- [github.com/PyCQA/isort: 6.1.0 → 7.0.0](https://github.com/PyCQA/isort/compare/6.1.0...7.0.0)
- [github.com/asottile/pyupgrade: v3.20.0 → v3.21.0](https://github.com/asottile/pyupgrade/compare/v3.20.0...v3.21.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-10-21 09:26:37 +02:00
David Thompson
b1b6f9f4dd
Add base manager setting to override default manager use (#747) (#766)
* Add `AUDITLOG_USE_BASE_MANAGER` setting configuration
* Adjust `LogEntry._get_changes_display_for_fk_field` behaviour for setting
* Adjust `log_update` and `log_m2m_changes` in `receivers.py` for setting
* Add `ModelManagerTest.test_use_base_manager_setting`
* Add entry in Usage documentation
* (In passing, fix a formatting issue in `usage.rst`)

The `AUDITLOG_USE_BASE_MANAGER` setting has a default of `False` to maintain
initial backwards compatibility with previous versions.
2025-10-18 13:55:43 +02:00
Youngkwang Yang
d417f30142
Drop 'Python 3.9' support (#773)
* Drop Python 3.9 support, set minimum version to 3.10

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

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

* Update CHANGELOG.md

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

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

* Fix lint error

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-10-17 17:51:53 +02:00
Dan Bentley
bd03eb6199
Amend setup configuration to include non-python package files (#769)
* Amend setup configuration to include non-python package files

Fixes: #767

Adds locale files created in: #762

* Amend render tests to use templatetag

Tests now use the public interface to render function.

Addresses regression in test coverage caused by adding templatetag to wheel.
2025-10-11 09:57:22 +02:00
Matt Cengia
74ba152a67
Fix copy-paste error in documentation heading (#760) 2025-10-10 10:57:42 +02:00
pre-commit-ci[bot]
85056b73c3
[pre-commit.ci] pre-commit autoupdate (#765)
updates:
- https://github.com/psf/blackhttps://github.com/psf/black-pre-commit-mirror
- [github.com/psf/black-pre-commit-mirror: 25.1.0 → 25.9.0](https://github.com/psf/black-pre-commit-mirror/compare/25.1.0...25.9.0)
- [github.com/PyCQA/isort: 6.0.1 → 6.1.0](https://github.com/PyCQA/isort/compare/6.0.1...6.1.0)
- [github.com/adamchainz/django-upgrade: 1.27.0 → 1.29.0](https://github.com/adamchainz/django-upgrade/compare/1.27.0...1.29.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-10-07 09:52:18 +02:00
Youngkwang Yang
d87b92923e
Prepare release 3.3.0 (#756) 2025-10-02 19:02:19 +02:00
Youngkwang Yang
3051d230b9
Fix None type mismatch in change detection (#763)
* Add test model and test cases for None value type mismatch

- issue #750 @The-Alchemist

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

* Add Korean translations

* Update translation files using LLM

* Add missing gettext wrappers for UI strings

* Add i18n support for audit log table headers

* Add justfile for i18n workflow

* Add `.mo` binary file

* Update CHANGELOG.md

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

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

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

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

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

* remove skipif

* Add changelog

---------

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


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

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-08 14:08:01 +02:00
pre-commit-ci[bot]
5379e1e5d0
[pre-commit.ci] pre-commit autoupdate (#753)
updates:
- [github.com/adamchainz/django-upgrade: 1.25.0 → 1.27.0](https://github.com/adamchainz/django-upgrade/compare/1.25.0...1.27.0)

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


Updates `actions/checkout` from 4 to 5
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-25 23:29:00 +02:00
Youngkwang Yang
8003b069c9
Extend CI and local test coverage to MySQL and SQLite (#744)
* Add test runner and improve test with multi databases

* Enhance cross-database compatibility and testing

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

* Add docker compose for testing

* Improve CI/CD with multi-database support

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

* Add `mysqlclient` deps

* fix minor

- Add mysqlclient deps
- upload coverage step

* Fix coverage upload name conflicts in CI workflow

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

* add AuditlogHistoryAdminMixin class

* add test cases for auditlog html render functions

* add audit log history view documentation

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

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

* Fix minor

* Add missing versionadded and configuration options for AuditlogHistoryAdminMixin

* Add missing test cases

* Update versionadded to 3.2.2 for AuditlogHistoryAdminMixin

* Update CHANGELOG.md

---------

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

Update the test to use the canonical name to ensure compatibility with
default Debian installations.
2025-07-11 15:02:58 +02:00
Hasan Ramezani
ca5f0aedfd
Prepare release 3.2.1 (#733) 2025-07-03 22:05:23 +02:00
The Alchemist
138e4fc948
added fixes for updates and deletes if AUDITLOG_STORE_JSON_CHANGES is True (#732) 2025-07-03 21:56:37 +02:00
Mahdi Rahimi
8fe73932a0
Add support for Django 5.2 (#730)
* Add support for Django 5.2

* Update setup.py

Co-authored-by: Hasan Ramezani <hasan.r67@gmail.com>

* docs: mention Django 5.2 in requirements and changelog

* docs: only keep Django 5.2 support line in changelog improvements

* docs: move and update Django 5.2 changelog entry with PR reference

* Update CHANGELOG.md

---------

Co-authored-by: Hasan Ramezani <hasan.r67@gmail.com>
2025-06-26 18:30:27 +02:00
Hasan Ramezani
6184ec8adb
Prepare release 3.2.0 (#729) 2025-06-26 13:27:43 +02:00
Hasan Ramezani
637e04c31e
Update pre-commit repos (#728) 2025-06-23 20:34:16 +02:00
Youngkwang Yang
af78976e53
Add support for custom masking functions (#725)
* Add test cases for the `mask_str` function

* Add custom masking function support through mask_callable

* Add test cases for custom masking function

* Update documentation for custom masking function

* fix test case

* rename `AUDITLOG_DEFAULT_MASK_CALLABLE` variable

-AUDITLOG_DEFAULT_MASK_CALLABLE to `AUDITLOG_MASK_CALLABLE`

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

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

* Update `CHANGELOG.md` to include mask function customization feature

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-06-09 16:59:59 +03:30
Youngkwang Yang
3a58e0a999
Fix get_field_value field default value handling for Django 6.0 compatibility (#726)
* 'get_field_value' - improve default value handling

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

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

* Add Test cases

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-06-09 16:30:28 +03:30
The Alchemist
b640df67a3
new setting: STORE_JSON_CHANGES that intelligently store JSON (#719)
* Branch that implements issue #675, basically, storing the correct JSON type that corresponds to the Python type (None -> null, 1 -> 1', not "1"`).

It's driven by a setting, AUDITLOG_ STORE_JSON_CHANGES , as recommended by @hramezani

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

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

* code formatting tweaks from @hramezani

* increasing test coverage

* added usage for AUDITLOG_STORE_JSON_CHANGES setting

* updated CHANGELOG with info on AUDITLOG_STORE_JSON_CHANGES field

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

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

* added another test for wrong setting type

* should not have committed temporary test changes

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

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

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-04-30 11:20:27 +02:00
Bahram Aghaei
ecdefde9ed
improve actor_email assigment; the previous was not intuitive enough to understand (#720) 2025-04-30 11:14:34 +02:00
Johannes Faigle
3f7f005377
Pin libraries to fix release (#718) 2025-04-26 12:57:00 +02:00
Hasan Ramezani
d1e7566668
Prepare release 3.1.1 (#716) 2025-04-17 10:34:55 +03:30
Johannes Faigle
df185e32db
CI: Add required pkginfo to release workflow (#715)
* CI: Add required pkginfo to release workflow

* Changelog: Add fix to 3.1.0 release
2025-04-16 12:24:11 +03:30
Johannes Faigle
cbcaff3569
Prepare release 3.1.0 (#714)
Closes #713
2025-04-15 19:21:10 +03:30
pre-commit-ci[bot]
6d7e8c7968
[pre-commit.ci] pre-commit autoupdate (#708)
updates:
- [github.com/PyCQA/flake8: 7.1.2 → 7.2.0](https://github.com/PyCQA/flake8/compare/7.1.2...7.2.0)
- [github.com/adamchainz/django-upgrade: 1.23.1 → 1.24.0](https://github.com/adamchainz/django-upgrade/compare/1.23.1...1.24.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-04-07 16:21:40 +03:30
sebastianmanger
6414b7aedb
Let CI also check for missing migrations (#706)
* Refactor: re-arrange test application

This was triggered by the need to run "python manage.py", which is impossible when serving the whole project in the same directory as is configured in INSTALLED_APPS ("auditlog_tests"). This setup is heavily inspired by other jazzband projects.

* Add check for missing migrations
2025-03-19 19:58:43 +03:30
Jakub Ječmínek
4c1d573981
Fix IntegrityError when cloning objects with pk=None (#664) (#707)
* Fix IntegrityError when cloning objects with pk=None

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

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

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-03-19 17:24:03 +03:30
sebastianmanger
939dd9b298
Use recommended manager (#705)
See https://docs.djangoproject.com/en/5.1/topics/db/managers/#default-managers
2025-03-11 18:02:52 +03:30
pre-commit-ci[bot]
de650b09c7
[pre-commit.ci] pre-commit autoupdate (#704)
updates:
- [github.com/PyCQA/isort: 6.0.0 → 6.0.1](https://github.com/PyCQA/isort/compare/6.0.0...6.0.1)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-03-10 14:20:27 +03:30
Hasan Ramezani
856ee0ae04
Fix changelog (#703) 2025-02-26 18:16:55 +03:30
Amirreza Ashouri
c4907bcd52
Add ability to globally mask fields by name on all models. (#702)
* Add ability to globally mask fields by name on all models.

Fixes https://github.com/jazzband/django-auditlog/issues/701

* Add feature explanation in `usage.rst` file.

* Add a record to CHANGELOG.md file.

* Add test coverage.
2025-02-24 12:05:04 +03:30
pre-commit-ci[bot]
fb3fac5cce
[pre-commit.ci] pre-commit autoupdate (#698)
updates:
- [github.com/psf/black: 24.10.0 → 25.1.0](https://github.com/psf/black/compare/24.10.0...25.1.0)
- [github.com/PyCQA/flake8: 7.1.1 → 7.1.2](https://github.com/PyCQA/flake8/compare/7.1.1...7.1.2)
- [github.com/PyCQA/isort: 5.13.2 → 6.0.0](https://github.com/PyCQA/isort/compare/5.13.2...6.0.0)
- [github.com/adamchainz/django-upgrade: 1.22.2 → 1.23.1](https://github.com/adamchainz/django-upgrade/compare/1.22.2...1.23.1)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-02-19 18:29:30 +03:30
hamsh
602c760b4c
add actor email (#641) 2025-01-30 18:33:51 +03:30
Hasan Ramezani
3e540bff6f
Add Python 3.13 support (#697) 2025-01-29 11:34:47 +03:30
pre-commit-ci[bot]
a27c045280
[pre-commit.ci] pre-commit autoupdate (#692)
updates:
- [github.com/asottile/pyupgrade: v3.19.0 → v3.19.1](https://github.com/asottile/pyupgrade/compare/v3.19.0...v3.19.1)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-01-22 19:49:19 +03:30
pre-commit-ci[bot]
6e51997728
[pre-commit.ci] pre-commit autoupdate (#690)
updates:
- [github.com/adamchainz/django-upgrade: 1.22.1 → 1.22.2](https://github.com/adamchainz/django-upgrade/compare/1.22.1...1.22.2)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-12-03 09:20:44 +01:00
David Haas
925f0dbaee
Add date_hierarchy on timestamp to admin view (#687)
* Update admin.py

allow filtering by timestamp with date_hierarchy in admin

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

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

---------

Co-authored-by: DavidHaas1 <david.haas@takeda.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-11-19 12:04:17 +01:00
dependabot[bot]
8150d35aef
Bump codecov/codecov-action from 4 to 5 in the github-actions group (#688)
Bumps the github-actions group with 1 update: [codecov/codecov-action](https://github.com/codecov/codecov-action).


Updates `codecov/codecov-action` from 4 to 5
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/v4...v5)

---
updated-dependencies:
- dependency-name: codecov/codecov-action
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-18 13:04:03 +01:00
Christopher Charbonneau Wells
5621777622
fix: use sender for m2m signal dispatch connection (#686)
* fix: use sender for m2m signal dispatch connection

This fix adds support for a use case where a single m2m through model is
used on multiple models. When the reciever is used for the dispatch uid
in this use case it cause duplicated logs because the through model
singal connection happens multiple times.

By changing the m2m signal connection to use the sender for the dispatch
uid this duplication is prevented because the signal connection only
happens once for the through model.

Refs: #685

* fix(format): apply black formatting

* add test and changelog entry

* remove unused import

* correct import sorting

* move change log message to correct section
2024-11-12 16:35:06 +01:00
Hoàng Quốc Hưng
d4f99c2729
Add AUDITLOG_TRUNCATE_CHANGES_DISPLAY and AUDITLOG_TRUNCATE_LIMIT (#684)
* Add AUDITLOG_TRUNCATE_CHANGES_DISPLAY and AUDITLOG_TRUNCATE_LIMIT

To configure how many characters will be truncated or disable it

* Add AUDITLOG_CHANGE_DISPLAY_TRUNCATE_LENGTH settings

to keep or truncate strings of `changes_display_dict` property at a variable length
2024-11-04 14:39:25 +01:00
pre-commit-ci[bot]
938e644177
[pre-commit.ci] pre-commit autoupdate (#663)
updates:
- [github.com/asottile/pyupgrade: v3.18.0 → v3.19.0](https://github.com/asottile/pyupgrade/compare/v3.18.0...v3.19.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-10-29 15:32:12 +01:00
Ali Abharya
b1ecc8f754
feat: Add truncate option to flush command (#681)
* [feat]: Add truncate option to flush command

* [test]: Add test cases for flush command with truncate

* [refactor]: Simplified truncate query class and remove redundant return statement

* [test]: Add test cases to test truncate for unsupported database vendor

* [docs]: Update change log
2024-10-21 18:12:29 +02:00
Hasan Ramezani
512cd28318
Update pre-commit (#679) 2024-10-17 19:22:57 +02:00
Hasan Ramezani
0634357119
Confirm Django 5.1 support and drop Django 3.2 support. (#677) 2024-10-17 18:53:55 +02:00
Hasan Ramezani
a53a6facfe
Drop Python 3.8 support (#678) 2024-10-17 18:40:21 +02:00
Cleiton de Lima
4c3ee0b36d
Added remote port (#671) 2024-10-07 15:52:34 +02:00
Cleiton de Lima
22dcbc4d06
Added --no-color args in test migration (#670) 2024-10-01 18:19:58 +02:00
Mohsin Raza
07a3a83828
update docs (#662) 2024-08-01 12:03:40 +03:30
pre-commit-ci[bot]
2958f58acd
[pre-commit.ci] pre-commit autoupdate (#657)
updates:
- [github.com/adamchainz/django-upgrade: 1.19.0 → 1.20.0](https://github.com/adamchainz/django-upgrade/compare/1.19.0...1.20.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-07-23 16:09:46 +03:30
pre-commit-ci[bot]
5759020015
[pre-commit.ci] pre-commit autoupdate (#654)
updates:
- [github.com/PyCQA/flake8: 7.0.0 → 7.1.0](https://github.com/PyCQA/flake8/compare/7.0.0...7.1.0)
- [github.com/adamchainz/django-upgrade: 1.18.0 → 1.19.0](https://github.com/adamchainz/django-upgrade/compare/1.18.0...1.19.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-07-02 11:34:22 +03:30
Bahram Aghaei
5bb701d821
Sync django query and postgres query (#653)
* run postgres query for rows that changes is null for them and there is value for changes_text

* add a test case to make when changes has value it wont be overwritten by changes_text
2024-06-12 16:00:29 +03:30
evanandrews-xrd
2c0bd0fac6
Use attname to get pk value (#650)
* Use `attname` to get pk value

* Add tests for model as primary key

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

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

* Add clarifying comments to _get_pk_value

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-06-11 14:30:28 +03:30
pre-commit-ci[bot]
92805e2084
[pre-commit.ci] pre-commit autoupdate (#651)
updates:
- [github.com/asottile/pyupgrade: v3.15.2 → v3.16.0](https://github.com/asottile/pyupgrade/compare/v3.15.2...v3.16.0)
- [github.com/adamchainz/django-upgrade: 1.17.0 → 1.18.0](https://github.com/adamchainz/django-upgrade/compare/1.17.0...1.18.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-06-11 11:21:31 +03:30
Hasan Ramezani
d412b2b16a
Update postgres to 14 (#649) 2024-05-27 13:22:46 +03:30
Hasan Ramezani
8af8011073
Fixed a problem when setting Value(None) in JSONField (#646) 2024-05-27 12:58:58 +03:30
pre-commit-ci[bot]
ba2c2e32be
[pre-commit.ci] pre-commit autoupdate (#642)
updates:
- [github.com/adamchainz/django-upgrade: 1.16.0 → 1.17.0](https://github.com/adamchainz/django-upgrade/compare/1.16.0...1.17.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-05-13 19:39:21 +02:00
Hasan Ramezani
a652eebfaf
Fixed problem when setting django.db.models.functions.Now() in DateTimeField (#635) 2024-04-29 23:38:09 +02:00
pre-commit-ci[bot]
1569051f48
[pre-commit.ci] pre-commit autoupdate (#637)
updates:
- [github.com/psf/black: 24.4.0 → 24.4.2](https://github.com/psf/black/compare/24.4.0...24.4.2)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-04-29 20:10:27 +02:00
pre-commit-ci[bot]
b768dc74f6
[pre-commit.ci] pre-commit autoupdate (#631)
updates:
- [github.com/psf/black: 24.3.0 → 24.4.0](https://github.com/psf/black/compare/24.3.0...24.4.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-04-15 19:49:22 +02:00
Hasan Ramezani
ceae2f9b4a
Prepare release 3.0.0 (#629) 2024-04-12 11:51:31 +02:00
Hasan Ramezani
7b035943c7
Fixed manuall logging when model is not registered (#627)
* Fixed manuall logging when model is not registered

* Move change log
2024-04-12 10:14:42 +02:00
nathan
a93d9ef665
Fix serialize __proxy__ objects before logging (#624)
* Fix serialize __proxy__ objects before logging

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

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

* Update CHANGELOG.md

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Hasan Ramezani <hasan.r67@gmail.com>
2024-04-08 12:06:36 +02:00
Hasan Ramezani
2f949dde25
Add note about V3 to readme (#626) 2024-04-07 21:36:13 +02:00
nathan
a0ae594425
Disable logging remote IP address (#620)
* Disable logging remote IP address

* Update auditlog/middleware.py

* Update CHANGELOG.md

* Update auditlog/middleware.py

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

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

* Update auditlog/middleware.py and add tests in ManyRelatedModelTest

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

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

---------

Co-authored-by: Hasan Ramezani <hasan.r67@gmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-03-31 01:37:39 +01:00
pre-commit-ci[bot]
5e2daa4c4c
[pre-commit.ci] pre-commit autoupdate (#621)
updates:
- [github.com/asottile/pyupgrade: v3.15.1 → v3.15.2](https://github.com/asottile/pyupgrade/compare/v3.15.1...v3.15.2)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-03-25 19:00:03 +01:00
Paul Morin
b2aff7e313
Correcting a typo in usage.rst (#615)
The code example for AUDITLOG_INCLUDE_TRACKING_MODELS made two distinct references to "model1" which seems to be a typo, should read "model2" on the second reference
2024-03-20 20:20:41 +01:00
dependabot[bot]
2b03d25343
Bump the github-actions group with 4 updates (#619)
Bumps the github-actions group with 4 updates: [actions/checkout](https://github.com/actions/checkout), [actions/setup-python](https://github.com/actions/setup-python), [actions/cache](https://github.com/actions/cache) and [codecov/codecov-action](https://github.com/codecov/codecov-action).


Updates `actions/checkout` from 3 to 4
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v3...v4)

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

Updates `actions/cache` from 3 to 4
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/v3...v4)

Updates `codecov/codecov-action` from 3 to 4
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: actions/setup-python
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: actions/cache
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: codecov/codecov-action
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-20 20:11:40 +01:00
Christian Clauss
5289482548
Keep GitHub Actions up to date with GitHub's Dependabot (#618)
Fixes warnings like at the bottom right of
https://github.com/jazzband/django-auditlog/actions/runs/8332793947

* 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
2024-03-20 18:26:14 +01:00
pre-commit-ci[bot]
156d655852
[pre-commit.ci] pre-commit autoupdate (#616)
updates:
- [github.com/psf/black: 24.2.0 → 24.3.0](https://github.com/psf/black/compare/24.2.0...24.3.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-03-18 20:54:55 +01:00
pre-commit-ci[bot]
ac720cd30e
[pre-commit.ci] pre-commit autoupdate (#610)
updates:
- [github.com/psf/black: 24.1.1 → 24.2.0](https://github.com/psf/black/compare/24.1.1...24.2.0)
- [github.com/asottile/pyupgrade: v3.15.0 → v3.15.1](https://github.com/asottile/pyupgrade/compare/v3.15.0...v3.15.1)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-02-19 18:59:39 +01:00
Pascal Mathis
e6fc81016c
fix: avoid exception in changes_display_dict when model is missing (#609) 2024-02-13 20:05:27 +01:00
pre-commit-ci[bot]
d78f813968
[pre-commit.ci] pre-commit autoupdate (#608)
updates:
- [github.com/adamchainz/django-upgrade: 1.15.0 → 1.16.0](https://github.com/adamchainz/django-upgrade/compare/1.15.0...1.16.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-02-13 11:28:25 +01:00
pre-commit-ci[bot]
b9bf01b31d
[pre-commit.ci] pre-commit autoupdate (#604)
* [pre-commit.ci] pre-commit autoupdate

updates:
- [github.com/psf/black: 23.12.1 → 24.1.1](https://github.com/psf/black/compare/23.12.1...24.1.1)

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

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

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-02-10 21:18:46 +01:00
Pascal Mathis
f3238c9bdb
feat: include LogEntry instance in post_log signal (#605) 2024-02-08 21:55:46 +01:00
pre-commit-ci[bot]
447347710d
[pre-commit.ci] pre-commit autoupdate (#601)
updates:
- [github.com/PyCQA/flake8: 6.1.0 → 7.0.0](https://github.com/PyCQA/flake8/compare/6.1.0...7.0.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-01-09 20:08:09 +03:30
Hasan Ramezani
1ce39c0149
Prepare release 3.0.0-beta.4 (#599) 2024-01-03 11:29:07 +03:30
Hasan Ramezani
387b0cec22
Confrim Django 5.0 and drop django 4.1 (#598) 2023-12-29 18:46:54 +03:30
pre-commit-ci[bot]
5523b50c2f
[pre-commit.ci] pre-commit autoupdate (#597)
updates:
- [github.com/psf/black: 23.12.0 → 23.12.1](https://github.com/psf/black/compare/23.12.0...23.12.1)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-12-28 17:23:21 +03:30
pre-commit-ci[bot]
91b6f2e11d
[pre-commit.ci] pre-commit autoupdate (#595)
updates:
- [github.com/psf/black: 23.11.0 → 23.12.0](https://github.com/psf/black/compare/23.11.0...23.12.0)
- [github.com/PyCQA/isort: 5.13.0 → 5.13.2](https://github.com/PyCQA/isort/compare/5.13.0...5.13.2)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-12-19 15:01:49 +03:30
pre-commit-ci[bot]
452f35eef2
[pre-commit.ci] pre-commit autoupdate (#594)
updates:
- [github.com/PyCQA/isort: 5.12.0 → 5.13.0](https://github.com/PyCQA/isort/compare/5.12.0...5.13.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-12-11 22:48:12 +03:30
Aleh Rymašeŭski
dbb50e462c
Handle ObjectDoesNotExist in evaluation of repr (#592) 2023-12-11 22:15:34 +03:00
errrken
140719eeb5
Conditionally disable / enable logging (#590)
* Update receivers.py

* Update signals.py

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

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

* Update signals.py

Removed trailing whitespace...

* Update tests.py

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

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

* Update CHANGELOG.md

* Update CHANGELOG.md

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Hasan Ramezani <hasan.r67@gmail.com>
2023-12-07 20:45:41 +03:00
pre-commit-ci[bot]
c581a6e647
[pre-commit.ci] pre-commit autoupdate (#588)
updates:
- [github.com/psf/black: 23.10.1 → 23.11.0](https://github.com/psf/black/compare/23.10.1...23.11.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-11-20 11:49:03 +03:30
Hasan Ramezani
5072f5d9b1
Prepare release 3.0.0-beta.3 (#587) 2023-11-14 11:02:31 +03:30
hamsh
9e987169da
use contextvar instead of threadlocal (#581)
* use contextvar instead of threadlocal

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

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

* Update auditlog/context.py

* update CHANGELOG.md

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Hasan Ramezani <hasan.r67@gmail.com>
2023-11-06 12:35:34 +03:30
James Gillard
a034588ec7
Confirm Python 3.12 support (#572)
* Confirm Python 3.12 support

* Remove dj4.2/py3.12 from tox.ini
2023-11-02 10:54:28 +03:30
pre-commit-ci[bot]
40ff8ae74d
[pre-commit.ci] pre-commit autoupdate (#579)
updates:
- [github.com/psf/black: 23.10.0 → 23.10.1](https://github.com/psf/black/compare/23.10.0...23.10.1)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-11-02 10:53:34 +03:30
Hugo van Kemenade
2bf675fceb
pip install . during RTD build (#578) 2023-10-27 19:31:36 +03:30
pre-commit-ci[bot]
6b7f4a7ed4
[pre-commit.ci] pre-commit autoupdate (#574)
updates:
- [github.com/psf/black: 23.9.1 → 23.10.0](https://github.com/psf/black/compare/23.9.1...23.10.0)
- [github.com/asottile/pyupgrade: v3.13.0 → v3.15.0](https://github.com/asottile/pyupgrade/compare/v3.13.0...v3.15.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-10-24 10:32:31 +03:30
Hasan Ramezani
e15a610dc9
Prepare release 3.0.0-beta.2 (#573) 2023-10-05 13:38:57 +02:00
Abdullah Alaqeel
d4ae5cc2b6
fix: change verbose_name in changes_text migration (#571) 2023-10-05 11:52:45 +02:00
Hasan Ramezani
2ebaf40476
Update pre-commit repos (#569) 2023-09-26 16:26:26 +02:00
Hasan Ramezani
e699e7d118
Update postgres to 13 (#568) 2023-09-26 08:29:41 +02:00
Abdullah Alaqeel
ccc59e85f1
fix: don't set the correlation_id if the AUDITLOG_CID_GETTER is None (#565) 2023-09-20 13:23:45 +03:00
pre-commit-ci[bot]
459f4b1521
[pre-commit.ci] pre-commit autoupdate (#564)
updates:
- [github.com/asottile/pyupgrade: v3.10.1 → v3.11.0](https://github.com/asottile/pyupgrade/compare/v3.10.1...v3.11.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-09-18 22:33:26 +02:00
Abdullah Alaqeel
720ab2795d
chore: add a check arg to the migration command (#563)
* chore: add a check arg to the migration command

* chore: add a check arg to the migration command
2023-09-13 18:03:04 +02:00
pre-commit-ci[bot]
991d47b1e2
[pre-commit.ci] pre-commit autoupdate (#562)
updates:
- [github.com/psf/black: 23.7.0 → 23.9.1](https://github.com/psf/black/compare/23.7.0...23.9.1)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-09-11 23:08:30 +02:00
Aleh Rymašeŭski
ac737fd55c
Stop deleting log entries in log_create (#559) 2023-09-11 16:16:37 +02:00
Abdullah Alaqeel
af31261946
fix: only fire the post_log signal when the log is created or when there is an error (#561) 2023-09-07 14:28:47 +02:00
Saeed Rasooli
858034b0c1
pass instance_old to post_log.send (#558) 2023-08-30 10:03:06 +02:00
Abdullah Alaqeel
9f1a09bde7
Prepare for 3.0.0 beta release (#555) 2023-08-29 13:46:48 +02:00
Aleh Rymašeŭski
c52786855b
Allow cascade deletion of auditlog entries (#556)
* Allow cascade deletion of auditlog entries

* Cache iteration over self.urls
2023-08-27 11:12:39 +02:00
Aleh Rymašeŭski
fb1ae7abed
Set history delete_related to False by default (#557)
This is a breaking change. The default behavior is to delete the
related objects, but it doesn't make sense to apply it to audit log
entries, at least not by default.
2023-08-24 17:25:56 +02:00
pre-commit-ci[bot]
699d8380b2
[pre-commit.ci] pre-commit autoupdate (#554)
updates:
- [github.com/PyCQA/flake8: 6.0.0 → 6.1.0](https://github.com/PyCQA/flake8/compare/6.0.0...6.1.0)
- [github.com/asottile/pyupgrade: v3.9.0 → v3.10.1](https://github.com/asottile/pyupgrade/compare/v3.9.0...v3.10.1)
- [github.com/adamchainz/django-upgrade: 1.14.0 → 1.14.1](https://github.com/adamchainz/django-upgrade/compare/1.14.0...1.14.1)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-08-22 13:20:42 +03:00
Abdullah Alaqeel
96a8ded086
chore: upgrade github action versions (#553) 2023-08-13 22:43:57 +02:00
Abdullah Alaqeel
134ef73723
feat: give users the option to run the json migration asyncly (#495) 2023-08-13 11:38:21 +02:00
Abdullah Alaqeel
f2591e420b
feat: collect all models including auto created ones and excluding non-managed ones (#550)
feat: automatically register m2m fields for models when opting to auto register models
2023-08-09 17:11:21 +02:00
pre-commit-ci[bot]
3dc4f1a02d
[pre-commit.ci] pre-commit autoupdate (#547)
updates:
- [github.com/psf/black: 23.3.0 → 23.7.0](https://github.com/psf/black/compare/23.3.0...23.7.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-07-17 23:44:44 +02:00
Hasan Ramezani
2c595f174c
Drop support for Python 3.7 (#546) 2023-07-13 23:56:08 +02:00
pre-commit-ci[bot]
060473919b
[pre-commit.ci] pre-commit autoupdate (#543)
updates:
- [github.com/asottile/pyupgrade: v3.8.0 → v3.9.0](https://github.com/asottile/pyupgrade/compare/v3.8.0...v3.9.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-07-13 23:29:53 +02:00
Hasan Ramezani
9bcc511e21
Fix a bug in serialized_data with F expressions (#544) 2023-07-13 08:53:47 +02:00
pre-commit-ci[bot]
875c6b789f
[pre-commit.ci] pre-commit autoupdate (#541)
updates:
- [github.com/asottile/pyupgrade: v3.7.0 → v3.8.0](https://github.com/asottile/pyupgrade/compare/v3.7.0...v3.8.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-07-03 22:08:05 +02:00
Hasan Ramezani
8f7dd324a6
Drop Django 4 support (#540) 2023-07-03 12:17:35 +02:00
pre-commit-ci[bot]
034384624d
[pre-commit.ci] pre-commit autoupdate (#534)
updates:
- [github.com/asottile/pyupgrade: v3.4.0 → v3.7.0](https://github.com/asottile/pyupgrade/compare/v3.4.0...v3.7.0)
- [github.com/adamchainz/django-upgrade: 1.13.0 → 1.14.0](https://github.com/adamchainz/django-upgrade/compare/1.13.0...1.14.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-06-24 15:24:50 +03:00
Fırat Kılıç
7f2a3e395d
Fix unnecessary log when adding already existed m2m record (#535) 2023-06-21 17:55:29 +03:30
Hasan Ramezani
45591463e8
Confirm Django 4.2 support (#529) 2023-05-25 11:15:01 +03:30
Rustam Astafeev
0d9fb8d6fc
fixed getting field's verbose_name (#508)
Co-authored-by: Hasan Ramezani <hasan.r67@gmail.com>
2023-05-18 01:59:11 +03:30
pre-commit-ci[bot]
bfb3a44296
[pre-commit.ci] pre-commit autoupdate (#527)
updates:
- [github.com/asottile/pyupgrade: v3.3.2 → v3.4.0](https://github.com/asottile/pyupgrade/compare/v3.3.2...v3.4.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-05-09 18:18:29 +03:30
pre-commit-ci[bot]
59b50907c0
[pre-commit.ci] pre-commit autoupdate (#523)
updates:
- [github.com/asottile/pyupgrade: v3.3.1 → v3.3.2](https://github.com/asottile/pyupgrade/compare/v3.3.1...v3.3.2)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-05-08 18:56:27 +03:30
Darren Maki
cd0d3ea311
Audit changes to FK fields when saved using *_id naming. (#525)
* Audit changes to FK fields when saved using *_id naming.

In Django you can save changes to a FK field on a model using the ID
field name (attname) in addition to the regular FK field name with:

    model.related_model_id = 42
    model.save(update_fields=["related_model_id'])

or:

    model.related_model = related_model
    model.save(update_fields=["related_model_id'])

as opposed to the more common:

    model.related_model = related_model
    model.save(update_fields=["related_model'])

This change ensures those model changes are logged properly.

* Apply suggested change from code review.

* Add a CHANGELOG entry for the fix.
2023-05-08 18:56:01 +03:30
pre-commit-ci[bot]
fa955cd5c7
[pre-commit.ci] pre-commit autoupdate (#521)
updates:
- [github.com/psf/black: 23.1.0 → 23.3.0](https://github.com/psf/black/compare/23.1.0...23.3.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-04-04 13:57:11 +03:30
Alieh Rymašeŭski
818c8ca16a
Fix GitHub actions deprecations (#520)
* Upgrade versions of github actions

See https://github.blog/changelog/2022-09-22-github-actions-all-actions-will-begin-running-on-node16-instead-of-node12/

* Update syntax of set-output directive

See https://github.blog/changelog/2022-10-11-github-actions-deprecating-save-state-and-set-output-commands/
2023-04-01 13:45:29 +03:30
pre-commit-ci[bot]
0cb3eb2b99
[pre-commit.ci] pre-commit autoupdate (#516)
updates:
- [github.com/adamchainz/django-upgrade: 1.12.0 → 1.13.0](https://github.com/adamchainz/django-upgrade/compare/1.12.0...1.13.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-02-20 21:17:01 +01:00
Joey Lange
98fe8d4173
Make M2M changes comply with JSONField properly (#514) 2023-02-15 15:10:27 +01:00
Hasan Ramezani
fcb6c4ce27
Fix a bug in audit log admin page when USE_TZ=False (#511)
Co-authored-by: Ebrahimi <z.ebrahimi.d80@gmail.com>
2023-02-10 17:52:07 +03:00
Hasan Ramezani
7aec22d38c
Add 2.2.2 changelog (#510) 2023-02-10 13:17:02 +01:00
pre-commit-ci[bot]
ec591800fe
[pre-commit.ci] pre-commit autoupdate (#507)
* [pre-commit.ci] pre-commit autoupdate

updates:
- [github.com/psf/black: 22.12.0 → 23.1.0](https://github.com/psf/black/compare/22.12.0...23.1.0)

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

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

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-02-09 09:19:41 +01:00
Hasan Ramezani
3a90087d32
Add null=True to ci field (#506)
* Add null=True to ci field

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

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

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-02-03 16:33:20 +03:00
pre-commit-ci[bot]
565a9bbef5
[pre-commit.ci] pre-commit autoupdate (#504)
updates:
- [github.com/PyCQA/isort: 5.11.4 → 5.12.0](https://github.com/PyCQA/isort/compare/5.11.4...5.12.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-01-31 13:17:32 +01:00
Aaron C. de Bruyn
06ae048378
Add ability to globally exclude fields by name on all models (#498)
Co-authored-by: Hasan Ramezani <hasan.r67@gmail.com>
2023-01-20 15:41:36 +01:00
Hasan Ramezani
7a7e2eb2ef
Run django main tests on Python >= 3.10 (#499) 2023-01-19 11:20:45 +03:00
Abdullah Alaqeel
f6b9e9f931
Always call set_actor (#484) 2023-01-04 15:05:32 +01:00
Thomas Steen Rasmussen
b9a86df456
raise AuditLogRegistrationError on invalid app in settings (#492)
Co-authored-by: Hasan Ramezani <hasan.r67@gmail.com>
2023-01-03 16:03:37 +01:00
Cleiton de Lima
ffa6d34b11
Fix repr of a json field in field changes (#489) 2022-12-30 08:28:55 +01:00
Aivars Kalvāns
7f8edd5456
Convert AUDITLOG_EXCLUDE_TRACKING_MODELS to tuple before concatenate (#488) 2022-12-29 09:09:56 +01:00
pre-commit-ci[bot]
dff0dd0fa5
[pre-commit.ci] pre-commit autoupdate (#485)
updates:
- [github.com/PyCQA/isort: v5.11.3 → 5.11.4](https://github.com/PyCQA/isort/compare/v5.11.3...5.11.4)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-12-28 09:52:13 +01:00
August Raack
c649629225
Change diff to evaluate PKs for FK relationships (#420)
* Change diff to evaluate PKs for FK relationships

The diff method now evaluates the primary keys for changes to determine
if a new LogEntry should be created. Previously, the diff method was
evaluating the string representation of the object. This was flawed
because cases can occur when a parent object has in memory changes to
its string string representation and the child related object is saved
prior to these in memory changes being persisted. In these cases a new
LogEntry object would be created erroneously. This cases is asserted
with a test and a regression test will verify the bug.

The consequence of these updates is that the ``LogEntry.changes`` field
now stores primary keys rather than string representations for related
objects. To keep the changes dictionary display unaffected by this
update, a method was added to the ``LogEntry`` model. This method looks
up the object display string from the stored foreign key. Exceptions
were written to handle backwards compatibility.

* Added test case to cover another bug

Because the string representation is not unique for every object, relying
on it to determine FK diffs may not capture all changes. This test case
shows another type of scenario that is fixed by comparing primary keys
rather than object string representations. This is likely occurring
fairly regularly but is hard to spot because it is an error of omission.

* Update to docstring and added changelog
2022-12-28 09:51:44 +01:00
August Raack
2a7fc23b29
Modify `change` field to be a json field. (#407)
* Modify ``change`` field to be a json field.

Storing the object changes as a json is preferred because it allows SQL
queries to access the change values. This work moves the burden of
handling json objects from an implementation of python's json library in
this package and puts it instead onto the ORM. Ultimately, having the
text field store the changes was leaving them less accessible to external
systems and code that is written outside the scope of the django
auditlog.

This change was accomplished by updating the field type on the model and
then removing the JSON dumps invocations on write and JSON loads
invocations on read. Test were updated to assert equality of
dictionaries rather than equality of JSON parsable text.

Separately, it was asserted that postgres will make these changes to
existing data. Therefore, existing postgres installations should update the
type of existing field values without issue.

* Add test coverage for messages exceeding char len

The "Modify change field to be a json field" commit reduced test
coverage on the mixins.py file by 0.03%. The reduction in coverage was
the result of reducing the number of operations required to achieve the
desired state. An additional test was added to increase previously
uncovered code. The net effect is an increase in test case coverage.

* Add line to changelog

Better markdown formatting

Co-authored-by: Hasan Ramezani <hasan.r67@gmail.com>

* Update CHANGELOG text format

More specific language in the improvement section regarding `LogEntry.change`

Co-authored-by: Hasan Ramezani <hasan.r67@gmail.com>

* Update migration to show Django version 4.0

Co-authored-by: Hasan Ramezani <hasan.r67@gmail.com>

* Update CHANGELOG to show breaking change

Running the migration to update the field type of `LogEntry.change` is a breaking change.

Co-authored-by: Hasan Ramezani <hasan.r67@gmail.com>

* Update serial order of migrations

* Adjust manager method for compatibility

The create log method on the LogEntry manager required an additional
kwarg for a call to create an instance regardless of a change or not.
This felt brittle anyway. The reason it had worked prior to these
changes was that the `change` kwarg was sending a string "null" and
not a None when there were no changes.

Co-authored-by: Hasan Ramezani <hasan.r67@gmail.com>
2022-12-28 09:50:35 +01:00
Abdullah Alaqeel
c65c38e539
Adding Custom Pre- and Post- Log Hooks (#483) 2022-12-27 20:14:51 +01:00
Abdullah Alaqeel
bc6d393390
Added support for Correlation ID
Co-authored-by: Hasan Ramezani <hasan.r67@gmail.com>
2022-12-23 15:09:32 +01:00
pre-commit-ci[bot]
63c88829e0
[pre-commit.ci] pre-commit autoupdate (#482)
updates:
- [github.com/PyCQA/isort: 5.10.1 → v5.11.3](https://github.com/PyCQA/isort/compare/5.10.1...v5.11.3)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-12-19 14:52:51 -05:00
Abdullah Alaqeel
a733cd0852
feat: Make timestamp in LogEntry overwritable (#478) 2022-12-19 08:43:29 +01:00
mrampant
971a4f42f8 - fixed unused import 2022-12-16 11:41:27 +03:30
mrampant
8e496aadea - using changes_dict to fix potential TypeError when changes are None in admin list view 2022-12-16 11:41:27 +03:30
ZahraEbrahimi01
703e3e4ba6
Complete translation with gettext_lazy (#474) 2022-12-14 11:31:17 +01:00
Thomas Steen Rasmussen
27f57a53ff
Fix LogEntry.changes_dict() to return {} when json.loads() returns None (#472)
Co-authored-by: Hasan Ramezani <hasan.r67@gmail.com>
2022-12-13 21:35:31 +01:00
pre-commit-ci[bot]
cd1ba3d01b
[pre-commit.ci] pre-commit autoupdate (#473)
updates:
- [github.com/psf/black: 22.10.0 → 22.12.0](https://github.com/psf/black/compare/22.10.0...22.12.0)
- [github.com/asottile/pyupgrade: v3.3.0 → v3.3.1](https://github.com/asottile/pyupgrade/compare/v3.3.0...v3.3.1)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-12-13 09:18:31 +01:00
pre-commit-ci[bot]
caf5daa2f8
[pre-commit.ci] pre-commit autoupdate (#466)
updates:
- [github.com/asottile/pyupgrade: v3.2.2 → v3.3.0](https://github.com/asottile/pyupgrade/compare/v3.2.2...v3.3.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-12-06 08:49:35 +01:00
Ihor Sychevskyi
bfaeeab74d
update docs folder link (#465) 2022-12-01 08:22:03 +01:00
pre-commit-ci[bot]
1674acae19
[pre-commit.ci] pre-commit autoupdate (#462)
updates:
- [github.com/PyCQA/flake8: 5.0.4 → 6.0.0](https://github.com/PyCQA/flake8/compare/5.0.4...6.0.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-11-28 21:03:19 +01:00
Hasan Ramezani
2a93c2086a
Prepare release 2.2.1 (#459) 2022-11-28 13:39:30 +03:00
Ihor Sychevskyi
b4dda75fc7
update readme link (#460) 2022-11-28 09:05:00 +01:00
Abdullah Alaqeel
4cdc756791
FIX: Parsing client IPv6 address when a proxy is involved (#457) 2022-11-22 08:44:59 +01:00
Hasan Ramezani
e23b091c99
Replace pkg_resources with importlib solution (#450) 2022-11-21 16:50:34 +01:00
Alieh Rymašeŭski
1ba3bd9d07
Disallow changing or deleting log entries (#449) 2022-11-21 16:26:23 +01:00
Youngkwang Yang
96275d5386
Add serialize_data setting (#452) 2022-11-15 13:05:35 +01:00
pre-commit-ci[bot]
a6ea91f1bc
[pre-commit.ci] pre-commit autoupdate (#451)
updates:
- [github.com/asottile/pyupgrade: v3.2.0 → v3.2.2](https://github.com/asottile/pyupgrade/compare/v3.2.0...v3.2.2)
- [github.com/adamchainz/django-upgrade: 1.11.0 → 1.12.0](https://github.com/adamchainz/django-upgrade/compare/1.11.0...1.12.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-11-14 22:14:53 +03:30
Hasan Ramezani
227b0d9fb5
Prepare release 2.2.0 (#434) 2022-11-07 14:48:05 +01:00
Hasan Ramezani
2b0bc9efa2
Replace the django.utils.timezone.utc by datetime.timezone.utc (#448)
Co-authored-by: Alieh Rymašeŭski <alieh.rymasheuski@gmail.com>
2022-11-07 14:26:51 +01:00
Hasan Ramezani
36eaaaa2a9
Confirm Python 3.11 support (#447) 2022-11-07 09:36:17 +01:00
Simon Kern
f71699a9d0
Added ACCESS action and enabled logging of object accesses (#436) 2022-11-07 08:51:00 +01:00
Robin Harms Oredsson
8fe776ae45
Option to disable logging on raw save and via context manager (#446)
* Disable on raw save prototype
* Contextmanager to disable instead of just raw - so we can catch m2m relations too
Co-authored-by: Hasan Ramezani <hasan.r67@gmail.com>
2022-11-04 09:12:06 +01:00
Hasan Ramezani
aa6d977f8b Update pyupgrade and django-upgrade pre-commit hooks 2022-11-02 16:01:51 +03:30
pre-commit-ci[bot]
90ce363b78
[pre-commit.ci] pre-commit autoupdate (#442)
updates:
- [github.com/asottile/pyupgrade: v3.0.0 → v3.1.0](https://github.com/asottile/pyupgrade/compare/v3.0.0...v3.1.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-10-17 22:18:03 +02:00
pre-commit-ci[bot]
c04f5354ef
[pre-commit.ci] pre-commit autoupdate (#440)
updates:
- [github.com/psf/black: 22.8.0 → 22.10.0](https://github.com/psf/black/compare/22.8.0...22.10.0)
- [github.com/asottile/pyupgrade: v2.38.2 → v3.0.0](https://github.com/asottile/pyupgrade/compare/v2.38.2...v3.0.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-10-10 22:08:10 +02:00
Youngkwang Yang
487b8ab5f4
Remove unnecessary code in created method. (#438) 2022-10-05 18:39:48 +02:00
pre-commit-ci[bot]
993cd847fb
[pre-commit.ci] pre-commit autoupdate (#435)
updates:
- [github.com/asottile/pyupgrade: v2.38.0 → v2.38.2](https://github.com/asottile/pyupgrade/compare/v2.38.0...v2.38.2)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-09-26 20:57:48 +02:00
Alieh Rymašeŭski
0f57525058
Support search by custom USERNAME_FIELD (#432) 2022-09-21 16:19:49 +02:00
Rahul Prasad
a56d0e6f78
added fix for OneToOneRel error happening in case of Polymorphic model (#429) 2022-09-21 08:53:10 +02:00
Mathieu Rampant
d74c118834
Added verbose field name in admin (#428)
Co-authored-by: Hasan Ramezani <hasan.r67@gmail.com>
2022-09-20 22:07:41 +02:00
pre-commit-ci[bot]
fb90112c50
[pre-commit.ci] pre-commit autoupdate (#430)
updates:
- [github.com/asottile/pyupgrade: v2.37.3 → v2.38.0](https://github.com/asottile/pyupgrade/compare/v2.37.3...v2.38.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-09-19 21:47:54 +02:00
pre-commit-ci[bot]
acada9edf9
[pre-commit.ci] pre-commit autoupdate (#425)
updates:
- [github.com/adamchainz/django-upgrade: 1.9.0 → 1.10.0](https://github.com/adamchainz/django-upgrade/compare/1.9.0...1.10.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-09-12 20:34:11 +02:00
pre-commit-ci[bot]
93602bd210
[pre-commit.ci] pre-commit autoupdate (#423)
updates:
- [github.com/psf/black: 22.6.0 → 22.8.0](https://github.com/psf/black/compare/22.6.0...22.8.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-09-05 20:31:07 +02:00
pre-commit-ci[bot]
527f870034
[pre-commit.ci] pre-commit autoupdate (#422)
updates:
- [github.com/adamchainz/django-upgrade: 1.8.0 → 1.9.0](https://github.com/adamchainz/django-upgrade/compare/1.8.0...1.9.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-08-29 20:26:04 +02:00
August Raack
777bd537e7
Add serialized object field (#412) 2022-08-21 21:45:50 +02:00
Hasan Ramezani
57423fcb3a
Add required Python and Django version to setup.py (#419) 2022-08-18 13:06:33 +02:00
François Magimel
174605d650
docs(readme): update the release section (#351)
Co-authored-by: Hasan Ramezani <hasan.r67@gmail.com>
2022-08-17 18:51:14 +02:00
Hasan Ramezani
18868aaaed Handle port in remote_addr 2022-08-17 13:37:22 +02:00
pre-commit-ci[bot]
c13d6ec88d
[pre-commit.ci] pre-commit autoupdate (#416)
updates:
- [github.com/adamchainz/django-upgrade: 1.7.0 → 1.8.0](https://github.com/adamchainz/django-upgrade/compare/1.7.0...1.8.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-08-15 20:03:15 +02:00
pre-commit-ci[bot]
a00d2c227f
[pre-commit.ci] pre-commit autoupdate (#413)
updates:
- [github.com/PyCQA/flake8: 5.0.2 → 5.0.4](https://github.com/PyCQA/flake8/compare/5.0.2...5.0.4)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-08-08 20:24:17 +02:00
Hasan Ramezani
eb5e873082
Add django-upgrade to pre-commit-config.yaml (#411) 2022-08-08 11:53:03 +02:00
Hasan Ramezani
830152f0f4 Fix code block in usage.rst 2022-08-05 15:29:01 +03:00
Hasan Ramezani
1cd7d9839d
Confirm Django 4.1 support (#406) 2022-08-03 17:26:56 +02:00
pre-commit-ci[bot]
4ed056bc2f
[pre-commit.ci] pre-commit autoupdate (#405)
updates:
- [github.com/PyCQA/flake8: 4.0.1 → 5.0.2](https://github.com/PyCQA/flake8/compare/4.0.1...5.0.2)
- [github.com/asottile/pyupgrade: v2.37.2 → v2.37.3](https://github.com/asottile/pyupgrade/compare/v2.37.2...v2.37.3)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-08-01 20:42:20 +02:00
Alieh Rymašeŭski
a24b79af0c
Display timestamps in server timezone (#404) 2022-08-01 20:41:41 +02:00
Hasan Ramezani
68cde8ffb9 Prepare release 2.1.1 2022-07-27 22:19:51 +02:00
Alieh Rymašeŭski
f68af3033d
Pin python-dateutil>=2.7.0 (#401) 2022-07-27 20:18:29 +02:00
pre-commit-ci[bot]
2f914c17ce [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/asottile/pyupgrade: v2.37.1 → v2.37.2](https://github.com/asottile/pyupgrade/compare/v2.37.1...v2.37.2)
2022-07-25 23:13:06 +02:00
pre-commit-ci[bot]
3e044444c3 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/asottile/pyupgrade: v2.34.0 → v2.37.1](https://github.com/asottile/pyupgrade/compare/v2.34.0...v2.37.1)
2022-07-11 20:13:43 +02:00
pre-commit-ci[bot]
93907b7a67 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/psf/black: 22.3.0 → 22.6.0](https://github.com/psf/black/compare/22.3.0...22.6.0)
2022-07-04 22:44:22 +03:00
Alieh Rymašeŭski
95929cd5b6 Add assertions for msg_short 2022-06-30 09:04:43 +02:00
Alieh Rymašeŭski
7861dae4f9 Show field values before deletion in the admin 2022-06-30 09:04:43 +02:00
Alieh Rymašeŭski
ed3ce76762 Remove a redundant comment
`profile = "black"` in `isort` block suggests that this is an `isort`
profile compatible with `black`.
2022-06-28 19:20:02 +02:00
Alieh Rymašeŭski
d7dd32af92 Switch MIDDLEWARE from a tuple to a list
This is a recommended change starting with Django 1.9.
2022-06-28 19:20:02 +02:00
Alieh Rymašeŭski
2ac014baa5 Remove a redundant exception handler
It duplicates the handler for its parent class Exception.
2022-06-28 19:20:02 +02:00
Alieh Rymašeŭski
68c7f44f9c Make checks for empty collections more pythonic 2022-06-28 19:20:02 +02:00
Alieh Rymašeŭski
121fe99cf5 Simplify is_authenticated check
This attribute is defined on request.user from Django 1.10 or a
comparably ancient version.
2022-06-28 19:20:02 +02:00
Hasan Ramezani
6b60d04e44 Fix documentation problem in usage.rst 2022-06-28 19:19:09 +02:00
Hasan Ramezani
7771075dc0 Remove South compatibility codes 2022-06-28 19:19:09 +02:00
Hasan Ramezani
b5edd50f02 Remove double underscores from tests name 2022-06-28 19:19:09 +02:00
Alieh Rymašeŭski
309eef5d2a Allow running tox environments in parallel 2022-06-28 19:08:36 +02:00
Alieh Rymašeŭski
5df31ea6b5 Fix env list in tox.ini 2022-06-28 19:08:36 +02:00
Alieh Rymašeŭski
fd35c61fda Silence an expected RuntimeWarning 2022-06-28 19:08:36 +02:00
Hasan Ramezani
4f4699bf6b Prepare release 2.1.0 2022-06-27 23:36:15 +02:00
Viktor
d928ff7f77 Return default value None if attribute choices doesn't exist
Co-authored-by: Hasan Ramezani <hasan.r67@gmail.com>
2022-06-27 09:08:19 +02:00
Hasan Ramezani
11dceb81b8
Add flake8 to pre-commit config (#387) 2022-06-18 20:50:25 +04:30
Hasan Ramezani
be973ca71f
Add pyupgrade to pre-commit-config (#385) 2022-06-15 15:48:08 +02:00
Alieh Rymašeŭski
10c47181bb
Add logic to track changes to m2m fields (#309) 2022-06-08 17:09:27 +02:00
Hasan Ramezani
2e9466d1b4
Change auditlogflush management command before_date query filter to apply the filter only on date part of timestamp (#384) 2022-06-08 16:47:13 +02:00
George Leslie-Waksman
128555fa29
Add date based auditlog filter for auditlogflush command (#365) 2022-05-31 21:35:50 +04:30
Youngkwang Yang
dd89c3cefb
Auditlog admin use list_select_related (#382)
* Use list_select_related

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

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

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-05-31 15:50:34 +00:00
Youngkwang Yang
957680e239
Fix n+1 query problem (#381) 2022-05-31 20:05:43 +09:00
Rich Rauenzahn
d9b0d76f3a
[tests] Check cases when reverse related field DoesNotExist. (#252) 2022-05-31 09:32:32 +02:00
George Leslie-Waksman
1e7d320a93
Add an index to the timestamp column (#364)
Many queries, including the default ordering, for the LogEntry
model rely on the timestamp field. Adding an index will ensure
reasonable scalability for large audit logs.
2022-05-31 09:01:13 +02:00
Alieh Rymašeŭski
bcd0d43566
Add set_actor context manager (#262) 2022-05-24 09:33:54 +02:00
pre-commit-ci[bot]
ba19a8ca35 [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2022-05-23 10:46:43 +02:00
Hasan Ramezani
46abfbdc2d Use Python3.7 for building the doc 2022-05-23 10:46:43 +02:00
Hasan Ramezani
340a01d348 Replace importlib with pkg_resources with.
Readthedoc uses Python3.7
Revert a30c8bbfc0
2022-05-23 10:46:43 +02:00
yeongkwang
32694b1324
Add register model using Django settings (#368)
Co-authored-by: Hasan Ramezani <hasan.r67@gmail.com>
2022-05-23 10:02:22 +02:00
Hasan Ramezani
8b47267a43 Revert "build: explicit the build system"
This reverts commit 7bc39e6d9d.
2022-05-23 09:25:25 +02:00
Hasan Ramezani
d5d65cae2a Update postgres image version to 12 in github action test workflow 2022-05-20 22:14:29 +02:00
Saurabh Kumar
cdd9f40d84 Disable add button in admin ui
Co-authored-by: Hasan Ramezani <hasan.r67@gmail.com>
2022-05-20 22:13:47 +02:00
Christian Barra
a93f53962a
Skip stringify if json (#355)
Co-authored-by: Hasan Ramezani <hasan.r67@gmail.com>
2022-05-10 05:53:18 +02:00
Hasan Ramezani
6f82d070a9 Change versionadded in masking fields documentation. 2022-05-09 13:40:31 +02:00
Hasan Ramezani
ea180c0065 Remove development note from README.md 2022-05-09 13:40:21 +02:00
Hasan Ramezani
d848c53d08 Prepare release of django-auditlog 2.0.0 2022-05-09 08:53:21 +02:00
Hasan Ramezani
e2d293730c Update postgres image version in github action test workflow 2022-05-09 08:53:21 +02:00
Hasan Ramezani
6791253ae0 Fix typo in CHANGELOG.md 2022-05-09 08:53:21 +02:00
Hasan Ramezani
e39fab3b83 Remove default_app_config configuration 2022-05-02 14:48:58 +02:00
Hasan Ramezani
4c0bfabab2 Fix lint entry in tox.ini 2022-05-01 13:48:26 +02:00
Hasan Ramezani
de0625b378 Drop Django 2.2 support. 2022-05-01 13:48:26 +02:00
pre-commit-ci[bot]
8a5fa3f236 [pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/psf/black: 22.1.0 → 22.3.0](https://github.com/psf/black/compare/22.1.0...22.3.0)
2022-04-05 15:04:09 +02:00
yeongkwang
8d8b6bad30 Fix small typo 2022-03-23 14:48:31 +04:30
Amin Alaee
bb5f99533e
Add mask_fields argument in register (#310)
Co-authored-by: Hasan Ramezani <hasan.r67@gmail.com>
2022-03-10 14:17:50 +01:00
Hasan Ramezani
dad4fb893b Use pre-commit as lint command 2022-03-09 11:31:58 +01:00
Hasan Ramezani
0499dfa4c8 Add black and isort to .pre-commit-config.yaml 2022-03-09 11:31:58 +01:00
Hasan Ramezani
629d8e62c6 Replace assertTrue with assertEqual 2022-03-07 21:14:24 +01:00
Samuel Gonçalves
77ef852706
enable use of replica database (#359)
* enable use of replica database

When you use replica database the django-auditlog try to write in the same database where object was read (replica). But this is a read only database and crash the application.

This changes saves always in the default database.

If you want to save in multiple databases or in a special one use `DATABASE_ROUTERS` to configure it.

Co-authored-by: Hasan Ramezani <hasan.r67@gmail.com>
2022-03-07 14:22:08 +01:00
François Magimel
665217d32f release: update the CHANGELOG date 2022-01-24 12:47:51 +01:00
François Magimel
7bc39e6d9d build: explicit the build system
Setuptools is needed to build this package.
2022-01-20 09:48:08 +01:00
François Magimel
a30c8bbfc0 docs(conf): replace pkg_resources with importlib
The doc is build with python 3.8, which includes the `importlib` API to
get the package version. Its use is recommended by `setuptools-scm` for
the doc.
2022-01-20 09:48:08 +01:00
Hasan Ramezani
f516aae4a6 Add django-jsonfield-backport to docs/requirements.txt to fix docs build. 2022-01-19 22:45:42 +01:00
François Magimel
d631bcac34 build(README): replace http with https for the doc 2022-01-19 11:49:17 +01:00
François Magimel
7356044693 build(setup): add some project URLs
Those URLs can be useful:
- the doc link
- the source link
- the tracker link.
2022-01-19 11:49:17 +01:00
Hasan Ramezani
505ee8d054 Fix black error. 2022-01-12 10:10:52 +01:00
Paul Backhouse
ffd8bb1e85 Apply default_auto_field to app config. 2022-01-11 10:42:05 +01:00
François Magimel
f91e524168
Refactor changelog (#343) 2022-01-10 18:36:46 +01:00
Hasan Ramezani
edc03e30fa Add Django4.0 to test pipeline. 2022-01-10 10:18:20 +01:00
Hasan Ramezani
f003f3332a Update syntax to Python 3.7+ 2022-01-10 10:18:20 +01:00
Hasan Ramezani
2c9d7047c9 Remove Django 3.1 support. 2022-01-10 10:18:20 +01:00
Hasan Ramezani
4771fe7830 Add missed change log. 2022-01-10 10:18:20 +01:00
Hasan Ramezani
4773a28cf4 Add Django main to test pipeline. 2022-01-10 10:18:20 +01:00
Hasan Ramezani
1d27bc3a04 Remove extra imports. 2022-01-10 10:18:20 +01:00
François Magimel
2e9668dc05
build: replace django-jsonfield with django-jsonfield-backport (#339)
The `django-jsonfield` module is not maintained anymore and raises some
errors with Django 4.0. So, as a recommendation in this package, and as
Django 2.2 is still maintained, `django-jsonfield-backport` will do the
job for the `JSONField` field.

Ref #43
Ref #334
2022-01-07 22:14:36 +01:00
Abdullah Elkady
54dc20e920
Support Django's save method update_fields kwarg (#336) 2022-01-07 14:23:37 +01:00
Basti
05c280575f replace ugettext with gettext for django4 2022-01-07 08:36:23 +01:00
m.babapoor@parspooyesh.com
33620c30a7 Add actor username to search fields 2022-01-06 16:53:51 +01:00
Charles Chan
c9fac5cfde Fix #327 - Remove non ascii characters (apostrophe) 2022-01-06 10:50:13 +01:00
m.babapoor@parspooyesh.com
194e1e4c93 Add content type to resource field 2022-01-05 21:04:42 +01:00
François Magimel
728fb0439e docs: use the latest django LTS to build docs
The latest django LTS is the the 3.2 version.
2022-01-05 15:01:32 +01:00
François Magimel
50beb70a19 ci(tox): remove hyphens in django version aliases
This will be more consistent with python version aliases
2022-01-05 15:01:32 +01:00
François Magimel
6fe3220a28 build(python): remove python 3.6 support 2022-01-05 15:01:32 +01:00
jazzband-bot
d5e192c285 Jazzband: Created local 'CODE_OF_CONDUCT.md' from remote 'CODE_OF_CONDUCT.md' 2022-01-04 11:27:52 +01:00
Andreas Hasenkopf
938431389f
Add a DB index to LogEntry's action field (#236)
For some applications the possibility to filter by the `action` field might be really interesting. However the lack of an index can lead to severe reduction of such queries.

The simplest solution: Let's a DB index on that field :)
2022-01-04 10:06:45 +01:00
Keith Bussell
620d716513
Update README to include updates to release strategy (#221)
Co-authored-by: François Magimel <magimel.francois@gmail.com>
2022-01-04 09:34:39 +01:00
Hasan Ramezani
3dee9f1555 Pin psycopg2-binary to 2.8.6 2021-11-04 20:40:23 +01:00
Hasan Ramezani
3cd34c4d07 Add .pre-commit-config.yaml file. 2021-11-04 20:40:23 +01:00
Hasan Ramezani
1b96d8f828 Add Python 3.10 to testing matrix. 2021-11-04 20:40:23 +01:00
Hasan Ramezani
e2913da1bb Replace MIDDLEWARE_CLASSES with MIDDLEWARE in docs. 2021-06-03 13:23:11 +02:00
Hasan Ramezani
1a437f4e40 Add DEFAULT_AUTO_FIELD to test settings. 2021-05-25 00:50:02 +02:00
Hasan Ramezani
da7b1441d0 Add Django 3.2 support. 2021-05-25 00:50:02 +02:00
Hasan Ramezani
ca5aa82714 Remove Django 3.0 support. 2021-05-25 00:50:02 +02:00
Panagiotis Simakis
457b04b448 Replace deprecated smart_text() with smart_str(). 2021-05-25 00:50:02 +02:00
Blas Isaias Fernández
55a66fc73a dict.iteritems was removed from python 3 2021-05-24 11:26:11 +02:00
Hasan Ramezani
2e477ab04a
Remove Python 3.5 support. (#301) 2021-05-24 01:25:46 +02:00
Jannis Leidel
2c6bf286b4
Remove note about maintenance. 2021-04-26 10:07:06 +02:00
Hasan Ramezani
d77803b3b4 Add Django supported versions to setup.py classifiers. 2020-12-07 21:27:17 +01:00
Hasan Ramezani
3e8d398c8e Remove Python 3.4 from setup.py classifiers. 2020-12-06 23:10:18 +01:00
Hasan Ramezani
6131430ff7 Change relative imports to absolute. 2020-12-06 23:10:18 +01:00
Hasan Ramezani
497c83fc83 Add isort and sort orders with isort. 2020-12-06 23:10:18 +01:00
Hasan Ramezani
f5bb5cb1a2 Add black and format files with black. 2020-12-06 23:10:18 +01:00
Hasan Ramezani
b700e40f65 Remove old django related codes. 2020-12-06 22:10:05 +01:00
Hasan Ramezani
f08b521b8b Add Supported Django versions badge to README. 2020-12-06 21:42:21 +01:00
Hasan Ramezani
e9cfdb2e48 Add Python 3.9 support. 2020-12-06 21:42:21 +01:00
Hasan Ramezani
793cb45960 Add Python 3.8 to setup.py classifiers. 2020-12-06 21:42:21 +01:00
Jannis Leidel
910089597e
Initial GitHub Actions workflow. (#283)
* Initial GitHub Actions workflow.

* Use correct Postgres port.

* Fix duplicate.

* Use POSTGRES_HOST?

* Fixing postgres config?

* Pass test env vars with Tox.

* Work around issue with Django 3.1.

* Write coverage file.

* Add release workflow.

* Remove Travis config file.

* Update .github/workflows/test.yml

Co-authored-by: Hugo van Kemenade <hugovk@users.noreply.github.com>

* Update auditlog_tests/tests.py

Co-authored-by: Hugo van Kemenade <hugovk@users.noreply.github.com>

* Update .github/workflows/test.yml

Co-authored-by: Hugo van Kemenade <hugovk@users.noreply.github.com>

* Update README.md

Co-authored-by: Hugo van Kemenade <hugovk@users.noreply.github.com>

* Add Django 3.1 to tox config.

Co-authored-by: Hugo van Kemenade <hugovk@users.noreply.github.com>
2020-11-26 10:45:20 +01:00
Jannis Leidel
50da34125c
Simplified travis config and added Jazzband release config. (#281)
* Simplified travis config and added Jazzband release config.

* Migrate to travis-ci.com.

* Split requirements to prevent env spoilage.

* Add docs requirements.

* Huh

* type

* Add psycopg2 doc requirements.
2020-11-25 22:15:14 +01:00
Jan-Jelle Kester
31418d54f2
Changes for Jazzband (#269)
* Update repository references to Jazzband
Issue #268

* Add Jazzband badge to README
Issue #268

* Add Jazzband contribution guideline
Issue #268
2020-10-23 12:16:27 +02:00
Jan-Jelle Kester
565239180e Review improvements 2020-09-07 16:52:32 +02:00
Jan-Jelle Kester
7bb17fd5d2 Remove 'Django' requirement to satisfy Travis 2020-09-07 16:52:32 +02:00
Jan-Jelle Kester
33fa249071 Allow higher versions of python-dateutil
Fixes #162, closes #184
2020-09-07 16:52:32 +02:00
Jan-Jelle Kester
346105dcf9 Use more generic .pk to get primary key instead of .id
Fixes #140
2020-09-07 16:52:32 +02:00
Jan-Jelle Kester
f4edfc0592 Management command improvements 2020-09-07 16:52:32 +02:00
Jan-Jelle Kester
f14f6b34ee Remove stale code 2020-09-07 16:52:32 +02:00
Jan-Jelle Kester
469fe362de Code improvements 2020-09-07 16:52:32 +02:00
Jan-Jelle Kester
228c5949fb Use admin for test site 2020-09-07 16:52:32 +02:00
Jan-Jelle Kester
c619b8c606 Support Django 3.1 2020-09-07 16:52:32 +02:00
Jan-Jelle Kester
2010b49d06 Fix Django 3.0 field choices diff 2020-09-07 16:52:32 +02:00
Jan-Jelle Kester
3acab4322b Drop Python 2, support Django 3.0, update dependencies 2020-09-07 16:52:32 +02:00
Jan-Jelle Kester
4e7c640ba0 Bump copyright year 2020-09-07 16:52:32 +02:00
Jan-Jelle Kester
ee8a700b1b Clean up project structure 2020-09-07 16:52:32 +02:00
135 changed files with 10481 additions and 3005 deletions

View file

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

13
.github/dependabot.yml vendored Normal file
View file

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

53
.github/workflows/release.yml vendored Normal file
View file

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

127
.github/workflows/test.yml vendored Normal file
View file

@ -0,0 +1,127 @@
name: Test
on: [push, pull_request]
jobs:
test-sqlite:
name: SQLite • Python ${{ matrix.python-version }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v6
- name: Setup Python and dependencies
uses: ./.github/actions/setup-python-deps
with:
python-version: ${{ matrix.python-version }}
cache-key-prefix: sqlite3
- name: Run tests
env:
TEST_DB_BACKEND: sqlite3
run: tox -v
- name: Upload coverage
uses: codecov/codecov-action@v5
with:
name: SQLite • Python ${{ matrix.python-version }}
test-postgres:
name: PostgreSQL • Python ${{ matrix.python-version }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13"]
services:
postgres:
image: postgres:15
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: auditlog
ports:
- 5432/tcp
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 10
steps:
- uses: actions/checkout@v6
- name: Setup Python and dependencies
uses: ./.github/actions/setup-python-deps
with:
python-version: ${{ matrix.python-version }}
cache-key-prefix: postgresql
- name: Run tests
env:
TEST_DB_BACKEND: postgresql
TEST_DB_HOST: localhost
TEST_DB_USER: postgres
TEST_DB_PASS: postgres
TEST_DB_NAME: auditlog
TEST_DB_PORT: ${{ job.services.postgres.ports[5432] }}
run: tox -v
- name: Upload coverage
uses: codecov/codecov-action@v5
with:
name: PostgreSQL • Python ${{ matrix.python-version }}
test-mysql:
name: MySQL • Python ${{ matrix.python-version }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13"]
services:
mysql:
image: mysql:8.4
env:
MYSQL_DATABASE: auditlog
MYSQL_USER: mysql
MYSQL_PASSWORD: mysql
MYSQL_ROOT_PASSWORD: mysql
ports:
- 3306/tcp
options: >-
--health-cmd="sh -c 'export MYSQL_PWD=\"$MYSQL_ROOT_PASSWORD\"; mysqladmin ping -h 127.0.0.1 --protocol=TCP -uroot --silent || exit 1'"
--health-interval=10s
--health-timeout=5s
--health-retries=20
steps:
- uses: actions/checkout@v6
- name: Install MySQL client libraries
run: |
sudo apt-get update
sudo apt-get install -y libmysqlclient-dev pkg-config mysql-client
- name: Setup Python and dependencies
uses: ./.github/actions/setup-python-deps
with:
python-version: ${{ matrix.python-version }}
cache-key-prefix: mysql
- name: Run tests
env:
TEST_DB_BACKEND: mysql
TEST_DB_HOST: 127.0.0.1
TEST_DB_USER: root
TEST_DB_PASS: mysql
TEST_DB_NAME: auditlog
TEST_DB_PORT: ${{ job.services.mysql.ports[3306] }}
run: tox -v
- name: Upload coverage
uses: codecov/codecov-action@v5
with:
name: MySQL • Python ${{ matrix.python-version }}

90
.gitignore vendored
View file

@ -1,12 +1,82 @@
*.db
*.egg-info
*.log
*.pot
*.pyc
.idea
.project
.pydevproject
.coverage*
venv/
### Python template
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
pip-wheel-metadata/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Sphinx documentation
docs/_build/
# pyenv
.python-version
# pipenv
Pipfile.lock
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
__pypackages__/
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
### JetBrains
.idea/
.vscode/

29
.pre-commit-config.yaml Normal file
View file

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

27
.readthedocs.yaml Normal file
View file

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

View file

@ -1,65 +0,0 @@
# Config file for automatic testing at travis-ci.org
dist: xenial
sudo: required
language: python
services:
- postgresql
addons:
postgresql: "10"
matrix:
include:
- python: 2.7
env: TOXENV=py27-django-111
- python: 3.4
env: TOXENV=py34-django-111
- python: 3.4
env: TOXENV=py34-django-20
- python: 3.5
env: TOXENV=py35-django-111
- python: 3.5
env: TOXENV=py35-django-20
- python: 3.5
env: TOXENV=py35-django-21
- python: 3.5
env: TOXENV=py35-django-22
- python: 3.6
env: TOXENV=py36-django-111
- python: 3.6
env: TOXENV=py36-django-20
- python: 3.6
env: TOXENV=py36-django-21
- python: 3.6
env: TOXENV=py36-django-22
- python: 3.7
env: TOXENV=py37-django-111
- python: 3.7
env: TOXENV=py37-django-20
- python: 3.7
env: TOXENV=py37-django-21
- python: 3.7
env: TOXENV=py37-django-22
fast_finish: true
install: pip install -r requirements-travis.txt
script: tox
after_success:
- codecov -e TOX_ENV
deploy:
provider: pypi
# PyPI credentials supplied with environment variables from repository settings
on:
repo: jjkester/django-auditlog
branch: stable
condition: $TOXENV = py36-django-20
edge: true

448
CHANGELOG.md Normal file
View file

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

46
CODE_OF_CONDUCT.md Normal file
View file

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

3
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,3 @@
[![Jazzband](https://jazzband.co/static/img/jazzband.svg)](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).

View file

@ -1,7 +1,6 @@
The MIT License (MIT)
Copyright (c) 2015 Jan-Jelle Kester
Copyright (c) 2019 Alieh Rymašeuski <alieh.rymasheuski@gmail.com>
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

View file

@ -1,26 +0,0 @@
# file GENERATED by distutils, do NOT edit
setup.py
src/auditlog/__init__.py
src/auditlog/admin.py
src/auditlog/apps.py
src/auditlog/context.py
src/auditlog/diff.py
src/auditlog/filters.py
src/auditlog/middleware.py
src/auditlog/mixins.py
src/auditlog/models.py
src/auditlog/receivers.py
src/auditlog/registry.py
src/auditlog/management/__init__.py
src/auditlog/management/commands/__init__.py
src/auditlog/management/commands/auditlogflush.py
src/auditlog/migrations/0001_initial.py
src/auditlog/migrations/0002_auto_support_long_primary_keys.py
src/auditlog/migrations/0003_logentry_remote_addr.py
src/auditlog/migrations/0004_logentry_detailed_object_repr.py
src/auditlog/migrations/0005_logentry_additional_data_verbose_name.py
src/auditlog/migrations/0006_object_pk_index.py
src/auditlog/migrations/0007_object_pk_type.py
src/auditlog/migrations/0008_timestamp_index.py
src/auditlog/migrations/0009_timestamp_id_index.py
src/auditlog/migrations/__init__.py

3
MANIFEST.in Normal file
View file

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

42
Makefile Normal file
View file

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

View file

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

24
auditlog/__init__.py Normal file
View file

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

62
auditlog/admin.py Normal file
View file

@ -0,0 +1,62 @@
from functools import cached_property
from django.contrib import admin
from django.contrib.auth import get_user_model
from django.utils.translation import gettext_lazy as _
from auditlog import get_logentry_model
from auditlog.filters import CIDFilter, ResourceTypeFilter
from auditlog.mixins import LogEntryAdminMixin
LogEntry = get_logentry_model()
@admin.register(LogEntry)
class LogEntryAdmin(admin.ModelAdmin, LogEntryAdminMixin):
date_hierarchy = "timestamp"
list_select_related = ["content_type", "actor"]
list_display = [
"created",
"resource_url",
"action",
"msg_short",
"user_url",
"cid_url",
]
search_fields = [
"timestamp",
"object_repr",
"changes",
"actor__first_name",
"actor__last_name",
f"actor__{get_user_model().USERNAME_FIELD}",
]
list_filter = ["action", ResourceTypeFilter, CIDFilter]
readonly_fields = ["created", "resource_url", "action", "user_url", "msg"]
fieldsets = [
(None, {"fields": ["created", "user_url", "resource_url", "cid"]}),
(_("Changes"), {"fields": ["action", "msg"]}),
]
def has_add_permission(self, request):
return False
def has_change_permission(self, request, obj=None):
return False
@cached_property
def _own_url_names(self):
return [pattern.name for pattern in self.urls if pattern.name]
def has_delete_permission(self, request, obj=None):
if (
request.resolver_match
and request.resolver_match.url_name not in self._own_url_names
):
# only allow cascade delete to satisfy delete_related flag
return super().has_delete_permission(request, obj)
return False
def get_queryset(self, request):
self.request = request
return super().get_queryset(request=request)

17
auditlog/apps.py Normal file
View file

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

70
auditlog/cid.py Normal file
View file

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

78
auditlog/conf.py Normal file
View file

@ -0,0 +1,78 @@
from django.conf import settings
# Register all models when set to True
settings.AUDITLOG_INCLUDE_ALL_MODELS = getattr(
settings, "AUDITLOG_INCLUDE_ALL_MODELS", False
)
# Exclude models in registration process
# It will be considered when `AUDITLOG_INCLUDE_ALL_MODELS` is True
settings.AUDITLOG_EXCLUDE_TRACKING_MODELS = getattr(
settings, "AUDITLOG_EXCLUDE_TRACKING_MODELS", ()
)
# Register models and define their logging behaviour
settings.AUDITLOG_INCLUDE_TRACKING_MODELS = getattr(
settings, "AUDITLOG_INCLUDE_TRACKING_MODELS", ()
)
# Exclude named fields across all models
settings.AUDITLOG_EXCLUDE_TRACKING_FIELDS = getattr(
settings, "AUDITLOG_EXCLUDE_TRACKING_FIELDS", ()
)
# Mask named fields across all models
settings.AUDITLOG_MASK_TRACKING_FIELDS = getattr(
settings, "AUDITLOG_MASK_TRACKING_FIELDS", ()
)
# Disable on raw save to avoid logging imports and similar
settings.AUDITLOG_DISABLE_ON_RAW_SAVE = getattr(
settings, "AUDITLOG_DISABLE_ON_RAW_SAVE", False
)
# CID
settings.AUDITLOG_CID_HEADER = getattr(
settings, "AUDITLOG_CID_HEADER", "x-correlation-id"
)
settings.AUDITLOG_CID_GETTER = getattr(settings, "AUDITLOG_CID_GETTER", None)
# migration
settings.AUDITLOG_TWO_STEP_MIGRATION = getattr(
settings, "AUDITLOG_TWO_STEP_MIGRATION", False
)
settings.AUDITLOG_USE_TEXT_CHANGES_IF_JSON_IS_NOT_PRESENT = getattr(
settings, "AUDITLOG_USE_TEXT_CHANGES_IF_JSON_IS_NOT_PRESENT", False
)
# Disable remote_addr field in database
settings.AUDITLOG_DISABLE_REMOTE_ADDR = getattr(
settings, "AUDITLOG_DISABLE_REMOTE_ADDR", False
)
# Number of characters at which changes_display_dict property should be shown
settings.AUDITLOG_CHANGE_DISPLAY_TRUNCATE_LENGTH = getattr(
settings, "AUDITLOG_CHANGE_DISPLAY_TRUNCATE_LENGTH", 140
)
# Use pure JSON for changes field
settings.AUDITLOG_STORE_JSON_CHANGES = getattr(
settings, "AUDITLOG_STORE_JSON_CHANGES", False
)
settings.AUDITLOG_MASK_CALLABLE = getattr(settings, "AUDITLOG_MASK_CALLABLE", None)
settings.AUDITLOG_LOGENTRY_MODEL = getattr(
settings, "AUDITLOG_LOGENTRY_MODEL", "auditlog.LogEntry"
)
# Use base model managers instead of default model managers
settings.AUDITLOG_USE_BASE_MANAGER = getattr(
settings, "AUDITLOG_USE_BASE_MANAGER", False
)
# Use string representation of referenced object in foreign key changes instead of its primary key
settings.AUDITLOG_USE_FK_STRING_REPRESENTATION = getattr(
settings, "AUDITLOG_USE_FK_STRING_REPRESENTATION", False
)

107
auditlog/context.py Normal file
View file

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

283
auditlog/diff.py Normal file
View file

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

33
auditlog/filters.py Normal file
View file

@ -0,0 +1,33 @@
from django.contrib.admin import SimpleListFilter
from django.utils.translation import gettext_lazy as _
class ResourceTypeFilter(SimpleListFilter):
title = _("Resource Type")
parameter_name = "resource_type"
def lookups(self, request, model_admin):
qs = model_admin.get_queryset(request)
types = qs.values_list("content_type_id", "content_type__model")
return list(types.order_by("content_type__model").distinct())
def queryset(self, request, queryset):
if self.value() is None:
return queryset
return queryset.filter(content_type_id=self.value())
class CIDFilter(SimpleListFilter):
title = _("Correlation ID")
parameter_name = "cid"
def lookups(self, request, model_admin):
return []
def has_output(self):
return True
def queryset(self, request, queryset):
if self.value() is None:
return queryset
return queryset.filter(cid=self.value())

Binary file not shown.

View file

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

Binary file not shown.

View file

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

View file

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

View file

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

70
auditlog/middleware.py Normal file
View file

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

View file

@ -0,0 +1,77 @@
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("contenttypes", "0001_initial"),
]
operations = [
migrations.CreateModel(
name="LogEntry",
fields=[
(
"id",
models.AutoField(
verbose_name="ID",
serialize=False,
auto_created=True,
primary_key=True,
),
),
("object_pk", models.TextField(verbose_name="object pk")),
(
"object_id",
models.PositiveIntegerField(
db_index=True, null=True, verbose_name="object id", blank=True
),
),
("object_repr", models.TextField(verbose_name="object representation")),
(
"action",
models.PositiveSmallIntegerField(
verbose_name="action",
choices=[(0, "create"), (1, "update"), (2, "delete")],
),
),
(
"changes",
models.TextField(verbose_name="change message", blank=True),
),
(
"timestamp",
models.DateTimeField(auto_now_add=True, verbose_name="timestamp"),
),
(
"actor",
models.ForeignKey(
related_name="+",
on_delete=django.db.models.deletion.SET_NULL,
verbose_name="actor",
blank=True,
to=settings.AUTH_USER_MODEL,
null=True,
),
),
(
"content_type",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="+",
verbose_name="content type",
to="contenttypes.ContentType",
),
),
],
options={
"ordering": ["-timestamp"],
"get_latest_by": "timestamp",
"verbose_name": "log entry",
"verbose_name_plural": "log entries",
},
bases=(models.Model,),
),
]

View file

@ -0,0 +1,17 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("auditlog", "0001_initial"),
]
operations = [
migrations.AlterField(
model_name="logentry",
name="object_id",
field=models.BigIntegerField(
db_index=True, null=True, verbose_name="object id", blank=True
),
),
]

View file

@ -0,0 +1,17 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("auditlog", "0002_auto_support_long_primary_keys"),
]
operations = [
migrations.AddField(
model_name="logentry",
name="remote_addr",
field=models.GenericIPAddressField(
null=True, verbose_name="remote address", blank=True
),
),
]

View file

@ -0,0 +1,15 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("auditlog", "0003_logentry_remote_addr"),
]
operations = [
migrations.AddField(
model_name="logentry",
name="additional_data",
field=models.JSONField(null=True, blank=True),
),
]

View file

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

View file

@ -0,0 +1,17 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("auditlog", "0005_logentry_additional_data_verbose_name"),
]
operations = [
migrations.AlterField(
model_name="logentry",
name="object_pk",
field=models.CharField(
verbose_name="object pk", max_length=255, db_index=True
),
),
]

View file

@ -0,0 +1,17 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("auditlog", "0006_object_pk_index"),
]
operations = [
migrations.AlterField(
model_name="logentry",
name="object_pk",
field=models.CharField(
verbose_name="object pk", max_length=255, db_index=True
),
),
]

View file

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

View file

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

View file

@ -0,0 +1,19 @@
# Generated by Django 4.0.3 on 2022-03-11 23:16
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("auditlog", "0009_alter_logentry_additional_data"),
]
operations = [
migrations.AlterField(
model_name="logentry",
name="timestamp",
field=models.DateTimeField(
auto_now_add=True, db_index=True, verbose_name="timestamp"
),
),
]

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

179
auditlog/mixins.py Normal file
View file

@ -0,0 +1,179 @@
from urllib.parse import unquote
from django import urls as urlresolvers
from django.conf import settings
from django.contrib import admin
from django.contrib.admin.views.main import PAGE_VAR
from django.core.exceptions import PermissionDenied
from django.http import HttpRequest
from django.template.response import TemplateResponse
from django.urls import path, reverse
from django.urls.exceptions import NoReverseMatch
from django.utils.html import format_html
from django.utils.text import capfirst
from django.utils.timezone import is_aware, localtime
from django.utils.translation import gettext_lazy as _
from auditlog import get_logentry_model
from auditlog.render import get_field_verbose_name, render_logentry_changes_html
from auditlog.signals import accessed
LogEntry = get_logentry_model()
MAX = 75
class LogEntryAdminMixin:
request: HttpRequest
CID_TITLE = _("Click to filter by records with this correlation id")
@admin.display(description=_("Created"))
def created(self, obj):
if is_aware(obj.timestamp):
return localtime(obj.timestamp)
return obj.timestamp
@admin.display(description=_("User"))
def user_url(self, obj):
if obj.actor:
app_label, model = settings.AUTH_USER_MODEL.split(".")
viewname = f"admin:{app_label}_{model.lower()}_change"
try:
link = urlresolvers.reverse(viewname, args=[obj.actor.pk])
except NoReverseMatch:
return "%s" % (obj.actor)
return format_html('<a href="{}">{}</a>', link, obj.actor)
return "system"
@admin.display(description=_("Resource"))
def resource_url(self, obj):
app_label, model = obj.content_type.app_label, obj.content_type.model
viewname = f"admin:{app_label}_{model}_change"
try:
args = [obj.object_pk] if obj.object_id is None else [obj.object_id]
link = urlresolvers.reverse(viewname, args=args)
except NoReverseMatch:
return obj.object_repr
else:
return format_html(
'<a href="{}">{} - {}</a>', link, obj.content_type, obj.object_repr
)
@admin.display(description=_("Changes"))
def msg_short(self, obj):
if obj.action in [LogEntry.Action.DELETE, LogEntry.Action.ACCESS]:
return "" # delete
changes = obj.changes_dict
s = "" if len(changes) == 1 else "s"
fields = ", ".join(changes.keys())
if len(fields) > MAX:
i = fields.rfind(" ", 0, MAX)
fields = fields[:i] + " .."
return "%d change%s: %s" % (len(changes), s, fields)
@admin.display(description=_("Changes"))
def msg(self, obj):
return render_logentry_changes_html(obj)
@admin.display(description="Correlation ID")
def cid_url(self, obj):
cid = obj.cid
if cid:
url = self._add_query_parameter("cid", cid)
return format_html(
'<a href="{}" title="{}">{}</a>', url, self.CID_TITLE, cid
)
def _add_query_parameter(self, key: str, value: str):
full_path = self.request.get_full_path()
delimiter = "&" if "?" in full_path else "?"
return f"{full_path}{delimiter}{key}={value}"
def field_verbose_name(self, obj, field_name: str):
"""
Use `auditlog.render.get_field_verbose_name` instead.
This method is kept for backward compatibility.
"""
return get_field_verbose_name(obj, field_name)
class LogAccessMixin:
def render_to_response(self, context, **response_kwargs):
obj = self.get_object()
accessed.send(obj.__class__, instance=obj)
return super().render_to_response(context, **response_kwargs)
class AuditlogHistoryAdminMixin:
"""
Add an audit log history view to a model admin.
"""
auditlog_history_template = "auditlog/object_history.html"
show_auditlog_history_link = False
auditlog_history_per_page = 10
def get_list_display(self, request):
list_display = list(super().get_list_display(request))
if self.show_auditlog_history_link and "auditlog_link" not in list_display:
list_display.append("auditlog_link")
return list_display
def get_urls(self):
opts = self.model._meta
info = opts.app_label, opts.model_name
my_urls = [
path(
"<path:object_id>/auditlog/",
self.admin_site.admin_view(self.auditlog_history_view),
name="%s_%s_auditlog" % info,
)
]
return my_urls + super().get_urls()
def auditlog_history_view(self, request, object_id, extra_context=None):
obj = self.get_object(request, unquote(object_id))
if not self.has_view_permission(request, obj):
raise PermissionDenied
log_entries = (
LogEntry.objects.get_for_object(obj)
.select_related("actor")
.order_by("-timestamp")
)
paginator = self.get_paginator(
request, log_entries, self.auditlog_history_per_page
)
page_number = request.GET.get(PAGE_VAR, 1)
page_obj = paginator.get_page(page_number)
page_range = paginator.get_elided_page_range(page_obj.number)
context = {
**self.admin_site.each_context(request),
"title": _("Audit log: %s") % obj,
"module_name": str(capfirst(self.model._meta.verbose_name_plural)),
"page_range": page_range,
"page_var": PAGE_VAR,
"pagination_required": paginator.count > self.auditlog_history_per_page,
"object": obj,
"opts": self.model._meta,
"log_entries": page_obj,
**(extra_context or {}),
}
return TemplateResponse(request, self.auditlog_history_template, context)
@admin.display(description=_("Audit log"))
def auditlog_link(self, obj):
opts = self.model._meta
url = reverse(
f"admin:{opts.app_label}_{opts.model_name}_auditlog",
args=[obj.pk],
)
return format_html('<a href="{}">{}</a>', url, _("View"))

655
auditlog/models.py Normal file
View file

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

198
auditlog/receivers.py Normal file
View file

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

385
auditlog/registry.py Normal file
View file

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

95
auditlog/render.py Normal file
View file

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

70
auditlog/signals.py Normal file
View file

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

View file

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

View file

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

View file

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

View file

View file

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

View file

6
auditlog_tests/admin.py Normal file
View file

@ -0,0 +1,6 @@
from django.contrib import admin
from auditlog.registry import auditlog
for model in auditlog.get_models():
admin.site.register(model)

0
auditlog_tests/auditlog Normal file
View file

View file

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

View file

@ -0,0 +1,138 @@
# Generated by Django 4.2.25 on 2025-10-14 04:17
import django.db.models.deletion
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("contenttypes", "0002_remove_content_type_name"),
]
operations = [
migrations.CreateModel(
name="CustomLogEntryModel",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"object_pk",
models.CharField(
db_index=True, max_length=255, verbose_name="object pk"
),
),
(
"object_id",
models.BigIntegerField(
blank=True, db_index=True, null=True, verbose_name="object id"
),
),
("object_repr", models.TextField(verbose_name="object representation")),
("serialized_data", models.JSONField(null=True)),
(
"action",
models.PositiveSmallIntegerField(
choices=[
(0, "create"),
(1, "update"),
(2, "delete"),
(3, "access"),
],
db_index=True,
verbose_name="action",
),
),
(
"changes_text",
models.TextField(blank=True, verbose_name="change message"),
),
("changes", models.JSONField(null=True, verbose_name="change message")),
(
"cid",
models.CharField(
blank=True,
db_index=True,
max_length=255,
null=True,
verbose_name="Correlation ID",
),
),
(
"remote_addr",
models.GenericIPAddressField(
blank=True, null=True, verbose_name="remote address"
),
),
(
"remote_port",
models.PositiveIntegerField(
blank=True, null=True, verbose_name="remote port"
),
),
(
"timestamp",
models.DateTimeField(
db_index=True,
default=django.utils.timezone.now,
verbose_name="timestamp",
),
),
(
"additional_data",
models.JSONField(
blank=True, null=True, verbose_name="additional data"
),
),
(
"actor_email",
models.CharField(
blank=True,
max_length=254,
null=True,
verbose_name="actor email",
),
),
("role", models.CharField(blank=True, max_length=100, null=True)),
(
"actor",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="+",
to=settings.AUTH_USER_MODEL,
verbose_name="actor",
),
),
(
"content_type",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="+",
to="contenttypes.contenttype",
verbose_name="content type",
),
),
],
options={
"verbose_name": "log entry",
"verbose_name_plural": "log entries",
"ordering": ["-timestamp"],
"get_latest_by": "timestamp",
"abstract": False,
},
),
]

View file

@ -0,0 +1,7 @@
from django.db import models
from auditlog.models import AbstractLogEntry
class CustomLogEntryModel(AbstractLogEntry):
role = models.CharField(max_length=100, null=True, blank=True)

View file

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

View file

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

View file

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

View file

@ -0,0 +1,12 @@
from auditlog.middleware import AuditlogMiddleware
class CustomAuditlogMiddleware(AuditlogMiddleware):
"""
Custom Middleware to couple the request's user role to log items.
"""
def get_extra_data(self, request):
context_data = super().get_extra_data(request)
context_data["role"] = "Role 1"
return context_data

View file

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,110 @@
"""
Settings file for the Auditlog test suite.
"""
import os
DEBUG = True
SECRET_KEY = "test"
TEST_DB_BACKEND = os.getenv("TEST_DB_BACKEND", "sqlite3")
INSTALLED_APPS = [
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.messages",
"django.contrib.sessions",
"django.contrib.admin",
"django.contrib.staticfiles",
"django.contrib.postgres",
"custom_logentry_app",
"auditlog",
"test_app",
]
MIDDLEWARE = [
"django.middleware.common.CommonMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
]
if os.environ.get("AUDITLOG_LOGENTRY_MODEL", None):
MIDDLEWARE = MIDDLEWARE + ["auditlog.middleware.AuditlogMiddleware"]
else:
MIDDLEWARE = MIDDLEWARE + ["middleware.CustomAuditlogMiddleware"]
if TEST_DB_BACKEND == "postgresql":
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": os.getenv(
"TEST_DB_NAME", "auditlog" + os.environ.get("TOX_PARALLEL_ENV", "")
),
"USER": os.getenv("TEST_DB_USER", "postgres"),
"PASSWORD": os.getenv("TEST_DB_PASS", ""),
"HOST": os.getenv("TEST_DB_HOST", "127.0.0.1"),
"PORT": os.getenv("TEST_DB_PORT", "5432"),
}
}
elif TEST_DB_BACKEND == "mysql":
DATABASES = {
"default": {
"ENGINE": "django.db.backends.mysql",
"NAME": os.getenv(
"TEST_DB_NAME", "auditlog" + os.environ.get("TOX_PARALLEL_ENV", "")
),
"USER": os.getenv("TEST_DB_USER", "root"),
"PASSWORD": os.getenv("TEST_DB_PASS", ""),
"HOST": os.getenv("TEST_DB_HOST", "127.0.0.1"),
"PORT": os.getenv("TEST_DB_PORT", "3306"),
"OPTIONS": {
"charset": "utf8mb4",
"init_command": "SET sql_mode='STRICT_TRANS_TABLES'",
},
}
}
elif TEST_DB_BACKEND == "sqlite3":
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": os.getenv(
"TEST_DB_NAME",
(
":memory:"
if os.getenv("TOX_PARALLEL_ENV")
else "test_auditlog.sqlite3"
),
),
}
}
else:
raise ValueError(f"Unsupported database backend: {TEST_DB_BACKEND}")
TEMPLATES = [
{
"APP_DIRS": True,
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [],
"OPTIONS": {
"context_processors": [
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
]
},
},
]
STATIC_URL = "/static/"
ROOT_URLCONF = "test_app.urls"
USE_TZ = True
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
AUDITLOG_LOGENTRY_MODEL = os.environ.get("AUDITLOG_LOGENTRY_MODEL", "auditlog.LogEntry")

View file

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

View file

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

175
auditlog_tests/test_view.py Normal file
View file

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

3305
auditlog_tests/tests.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,177 +1,20 @@
# Makefile for Sphinx documentation
# Minimal makefile for Sphinx documentation
#
# You can set these variables from the command line.
SPHINXOPTS =
SPHINXBUILD = sphinx-build
PAPER =
BUILDDIR = build
# User-friendly check for sphinx-build
ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1)
$(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/)
endif
# Internal variables.
PAPEROPT_a4 = -D latex_paper_size=a4
PAPEROPT_letter = -D latex_paper_size=letter
ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source
# the i18n builder cannot share the environment and doctrees with the others
I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source
.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext
# You can set these variables from the command line, and also
# from the environment for the first two.
SPHINXOPTS ?=
SPHINXBUILD ?= sphinx-build
SOURCEDIR = source
BUILDDIR = _build
# Put it first so that "make" without argument is like "make help".
help:
@echo "Please use \`make <target>' where <target> is one of"
@echo " html to make standalone HTML files"
@echo " dirhtml to make HTML files named index.html in directories"
@echo " singlehtml to make a single large HTML file"
@echo " pickle to make pickle files"
@echo " json to make JSON files"
@echo " htmlhelp to make HTML files and a HTML help project"
@echo " qthelp to make HTML files and a qthelp project"
@echo " devhelp to make HTML files and a Devhelp project"
@echo " epub to make an epub"
@echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter"
@echo " latexpdf to make LaTeX files and run them through pdflatex"
@echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx"
@echo " text to make text files"
@echo " man to make manual pages"
@echo " texinfo to make Texinfo files"
@echo " info to make Texinfo files and run them through makeinfo"
@echo " gettext to make PO message catalogs"
@echo " changes to make an overview of all changed/added/deprecated items"
@echo " xml to make Docutils-native XML files"
@echo " pseudoxml to make pseudoxml-XML files for display purposes"
@echo " linkcheck to check all external links for integrity"
@echo " doctest to run all doctests embedded in the documentation (if enabled)"
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
clean:
rm -rf $(BUILDDIR)/*
.PHONY: help Makefile
html:
$(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html
@echo
@echo "Build finished. The HTML pages are in $(BUILDDIR)/html."
dirhtml:
$(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml
@echo
@echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml."
singlehtml:
$(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml
@echo
@echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml."
pickle:
$(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle
@echo
@echo "Build finished; now you can process the pickle files."
json:
$(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json
@echo
@echo "Build finished; now you can process the JSON files."
htmlhelp:
$(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp
@echo
@echo "Build finished; now you can run HTML Help Workshop with the" \
".hhp project file in $(BUILDDIR)/htmlhelp."
qthelp:
$(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp
@echo
@echo "Build finished; now you can run "qcollectiongenerator" with the" \
".qhcp project file in $(BUILDDIR)/qthelp, like this:"
@echo "# qcollectiongenerator $(BUILDDIR)/qthelp/django-auditlog.qhcp"
@echo "To view the help file:"
@echo "# assistant -collectionFile $(BUILDDIR)/qthelp/django-auditlog.qhc"
devhelp:
$(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp
@echo
@echo "Build finished."
@echo "To view the help file:"
@echo "# mkdir -p $$HOME/.local/share/devhelp/django-auditlog"
@echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/django-auditlog"
@echo "# devhelp"
epub:
$(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub
@echo
@echo "Build finished. The epub file is in $(BUILDDIR)/epub."
latex:
$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
@echo
@echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex."
@echo "Run \`make' in that directory to run these through (pdf)latex" \
"(use \`make latexpdf' here to do that automatically)."
latexpdf:
$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
@echo "Running LaTeX files through pdflatex..."
$(MAKE) -C $(BUILDDIR)/latex all-pdf
@echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
latexpdfja:
$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
@echo "Running LaTeX files through platex and dvipdfmx..."
$(MAKE) -C $(BUILDDIR)/latex all-pdf-ja
@echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
text:
$(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text
@echo
@echo "Build finished. The text files are in $(BUILDDIR)/text."
man:
$(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man
@echo
@echo "Build finished. The manual pages are in $(BUILDDIR)/man."
texinfo:
$(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
@echo
@echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo."
@echo "Run \`make' in that directory to run these through makeinfo" \
"(use \`make info' here to do that automatically)."
info:
$(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
@echo "Running Texinfo files through makeinfo..."
make -C $(BUILDDIR)/texinfo info
@echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo."
gettext:
$(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale
@echo
@echo "Build finished. The message catalogs are in $(BUILDDIR)/locale."
changes:
$(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes
@echo
@echo "The overview file is in $(BUILDDIR)/changes."
linkcheck:
$(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck
@echo
@echo "Link check complete; look for any errors in the above output " \
"or in $(BUILDDIR)/linkcheck/output.txt."
doctest:
$(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest
@echo "Testing of doctests in the sources finished, look at the " \
"results in $(BUILDDIR)/doctest/output.txt."
xml:
$(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml
@echo
@echo "Build finished. The XML files are in $(BUILDDIR)/xml."
pseudoxml:
$(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml
@echo
@echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml."
# Catch-all target: route all unknown targets to Sphinx using the new
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
%: Makefile
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)

View file

@ -1,242 +1,35 @@
@ECHO OFF
REM Command file for Sphinx documentation
if "%SPHINXBUILD%" == "" (
set SPHINXBUILD=sphinx-build
)
set BUILDDIR=build
set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% source
set I18NSPHINXOPTS=%SPHINXOPTS% source
if NOT "%PAPER%" == "" (
set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS%
set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS%
)
if "%1" == "" goto help
if "%1" == "help" (
:help
echo.Please use `make ^<target^>` where ^<target^> is one of
echo. html to make standalone HTML files
echo. dirhtml to make HTML files named index.html in directories
echo. singlehtml to make a single large HTML file
echo. pickle to make pickle files
echo. json to make JSON files
echo. htmlhelp to make HTML files and a HTML help project
echo. qthelp to make HTML files and a qthelp project
echo. devhelp to make HTML files and a Devhelp project
echo. epub to make an epub
echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter
echo. text to make text files
echo. man to make manual pages
echo. texinfo to make Texinfo files
echo. gettext to make PO message catalogs
echo. changes to make an overview over all changed/added/deprecated items
echo. xml to make Docutils-native XML files
echo. pseudoxml to make pseudoxml-XML files for display purposes
echo. linkcheck to check all external links for integrity
echo. doctest to run all doctests embedded in the documentation if enabled
goto end
)
if "%1" == "clean" (
for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i
del /q /s %BUILDDIR%\*
goto end
)
%SPHINXBUILD% 2> nul
if errorlevel 9009 (
echo.
echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
echo.installed, then set the SPHINXBUILD environment variable to point
echo.to the full path of the 'sphinx-build' executable. Alternatively you
echo.may add the Sphinx directory to PATH.
echo.
echo.If you don't have Sphinx installed, grab it from
echo.http://sphinx-doc.org/
exit /b 1
)
if "%1" == "html" (
%SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html
if errorlevel 1 exit /b 1
echo.
echo.Build finished. The HTML pages are in %BUILDDIR%/html.
goto end
)
if "%1" == "dirhtml" (
%SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml
if errorlevel 1 exit /b 1
echo.
echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml.
goto end
)
if "%1" == "singlehtml" (
%SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml
if errorlevel 1 exit /b 1
echo.
echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml.
goto end
)
if "%1" == "pickle" (
%SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle
if errorlevel 1 exit /b 1
echo.
echo.Build finished; now you can process the pickle files.
goto end
)
if "%1" == "json" (
%SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json
if errorlevel 1 exit /b 1
echo.
echo.Build finished; now you can process the JSON files.
goto end
)
if "%1" == "htmlhelp" (
%SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp
if errorlevel 1 exit /b 1
echo.
echo.Build finished; now you can run HTML Help Workshop with the ^
.hhp project file in %BUILDDIR%/htmlhelp.
goto end
)
if "%1" == "qthelp" (
%SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp
if errorlevel 1 exit /b 1
echo.
echo.Build finished; now you can run "qcollectiongenerator" with the ^
.qhcp project file in %BUILDDIR%/qthelp, like this:
echo.^> qcollectiongenerator %BUILDDIR%\qthelp\django-auditlog.qhcp
echo.To view the help file:
echo.^> assistant -collectionFile %BUILDDIR%\qthelp\django-auditlog.ghc
goto end
)
if "%1" == "devhelp" (
%SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp
if errorlevel 1 exit /b 1
echo.
echo.Build finished.
goto end
)
if "%1" == "epub" (
%SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub
if errorlevel 1 exit /b 1
echo.
echo.Build finished. The epub file is in %BUILDDIR%/epub.
goto end
)
if "%1" == "latex" (
%SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex
if errorlevel 1 exit /b 1
echo.
echo.Build finished; the LaTeX files are in %BUILDDIR%/latex.
goto end
)
if "%1" == "latexpdf" (
%SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex
cd %BUILDDIR%/latex
make all-pdf
cd %BUILDDIR%/..
echo.
echo.Build finished; the PDF files are in %BUILDDIR%/latex.
goto end
)
if "%1" == "latexpdfja" (
%SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex
cd %BUILDDIR%/latex
make all-pdf-ja
cd %BUILDDIR%/..
echo.
echo.Build finished; the PDF files are in %BUILDDIR%/latex.
goto end
)
if "%1" == "text" (
%SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text
if errorlevel 1 exit /b 1
echo.
echo.Build finished. The text files are in %BUILDDIR%/text.
goto end
)
if "%1" == "man" (
%SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man
if errorlevel 1 exit /b 1
echo.
echo.Build finished. The manual pages are in %BUILDDIR%/man.
goto end
)
if "%1" == "texinfo" (
%SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo
if errorlevel 1 exit /b 1
echo.
echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo.
goto end
)
if "%1" == "gettext" (
%SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale
if errorlevel 1 exit /b 1
echo.
echo.Build finished. The message catalogs are in %BUILDDIR%/locale.
goto end
)
if "%1" == "changes" (
%SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes
if errorlevel 1 exit /b 1
echo.
echo.The overview file is in %BUILDDIR%/changes.
goto end
)
if "%1" == "linkcheck" (
%SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck
if errorlevel 1 exit /b 1
echo.
echo.Link check complete; look for any errors in the above output ^
or in %BUILDDIR%/linkcheck/output.txt.
goto end
)
if "%1" == "doctest" (
%SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest
if errorlevel 1 exit /b 1
echo.
echo.Testing of doctests in the sources finished, look at the ^
results in %BUILDDIR%/doctest/output.txt.
goto end
)
if "%1" == "xml" (
%SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml
if errorlevel 1 exit /b 1
echo.
echo.Build finished. The XML files are in %BUILDDIR%/xml.
goto end
)
if "%1" == "pseudoxml" (
%SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml
if errorlevel 1 exit /b 1
echo.
echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml.
goto end
)
:end
@ECHO OFF
pushd %~dp0
REM Command file for Sphinx documentation
if "%SPHINXBUILD%" == "" (
set SPHINXBUILD=sphinx-build
)
set SOURCEDIR=source
set BUILDDIR=_build
if "%1" == "" goto help
%SPHINXBUILD% >NUL 2>NUL
if errorlevel 9009 (
echo.
echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
echo.installed, then set the SPHINXBUILD environment variable to point
echo.to the full path of the 'sphinx-build' executable. Alternatively you
echo.may add the Sphinx directory to PATH.
echo.
echo.If you don't have Sphinx installed, grab it from
echo.https://sphinx-doc.org/
exit /b 1
)
%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
goto end
:help
%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
:end
popd

6
docs/requirements.txt Normal file
View file

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

View file

@ -1,267 +1,72 @@
# -*- coding: utf-8 -*-
# Configuration file for the Sphinx documentation builder.
#
# django-auditlog documentation build configuration file, created by
# sphinx-quickstart on Wed Nov 6 20:39:48 2013.
#
# This file is execfile()d with the current directory set to its
# containing dir.
#
# Note that not all possible configuration values are present in this
# autogenerated file.
#
# All configuration values have a default; values that are commented out
# serve to show the default.
# This file only contains a selection of the most common options. For a full
# list see the documentation:
# https://www.sphinx-doc.org/en/master/usage/configuration.html
# -- Preliminary -------------------------------------------------------------
import sys
import os
import sphinx_rtd_theme
import sys
from datetime import date
from auditlog import __version__
# -- Path setup --------------------------------------------------------------
# If extensions (or modules to document with autodoc) are in another directory,
# add these directories to sys.path here. If the directory is relative to the
# documentation root, use os.path.abspath to make it absolute, like shown here.
sys.path.insert(0, os.path.abspath('../../src'))
# Django settings
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'testproject.settings')
from django.conf import settings
settings.configure()
# Add sources folder
# -- General configuration ------------------------------------------------
sys.path.insert(0, os.path.abspath("../../auditlog_tests"))
# If your documentation needs a minimal Sphinx version, state it here.
#needs_sphinx = '1.0'
# Setup Django for autodoc
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_settings")
import django # noqa: E402
django.setup()
# -- Project information -----------------------------------------------------
project = "django-auditlog"
author = "Jan-Jelle Kester and contributors"
copyright = f"2013-{date.today().year}, {author}"
release = __version__
# for example take major/minor
version = ".".join(release.split(".")[:2])
# -- General configuration ---------------------------------------------------
# Add any Sphinx extension module names here, as strings. They can be
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones.
extensions = [
'sphinx.ext.autodoc',
'sphinx.ext.viewcode',
"sphinx.ext.autodoc",
"sphinx.ext.viewcode",
]
# Master document that contains the root table of contents
master_doc = "index"
# Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates']
# The suffix of source filenames.
source_suffix = '.rst'
# The encoding of source files.
#source_encoding = 'utf-8-sig'
# The master toctree document.
master_doc = 'index'
# General information about the project.
project = u'django-auditlog'
copyright = u'2017, Jan-Jelle Kester and contributors'
# The version info for the project you're documenting, acts as replacement for
# |version| and |release|, also used in various other places throughout the
# built documents.
#
# The short X.Y version.
version = '0.6'
# The full version, including alpha/beta/rc tags.
release = '0.6.9'
# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
#language = None
# There are two options for replacing |today|: either, you set today to some
# non-false value, then it is used:
#today = ''
# Else, today_fmt is used as the format for a strftime call.
#today_fmt = '%B %d, %Y'
templates_path = ["_templates"]
# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
exclude_patterns = []
# This pattern also affects html_static_path and html_extra_path.
exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
# The reST default role (used for this markup: `text`) to use for all
# documents.
#default_role = None
# If true, '()' will be appended to :func: etc. cross-reference text.
#add_function_parentheses = True
# If true, the current module name will be prepended to all description
# unit titles (such as .. function::).
#add_module_names = True
# If true, sectionauthor and moduleauthor directives will be shown in the
# output. They are ignored by default.
#show_authors = False
# The name of the Pygments (syntax highlighting) style to use.
pygments_style = 'sphinx'
# A list of ignored prefixes for module index sorting.
#modindex_common_prefix = []
# If true, keep warnings as "system message" paragraphs in the built documents.
#keep_warnings = False
# -- Options for HTML output ----------------------------------------------
# -- Options for HTML output -------------------------------------------------
# The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes.
html_theme = 'sphinx_rtd_theme'
# Theme options are theme-specific and customize the look and feel of a theme
# further. For a list of options available for each theme, see the
# documentation.
#html_theme_options = {}
# Add any paths that contain custom themes here, relative to this directory.
html_theme_path = [sphinx_rtd_theme.get_html_theme_path()]
# The name for this set of Sphinx documents. If None, it defaults to
# "<project> v<release> documentation".
#html_title = None
# A shorter title for the navigation bar. Default is the same as html_title.
#html_short_title = None
# The name of an image file (relative to this directory) to place at the top
# of the sidebar.
#html_logo = None
# The name of an image file (within the static path) to use as favicon of the
# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32
# pixels large.
#html_favicon = None
#
html_theme = "sphinx_rtd_theme"
# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
html_static_path = ['_static']
# Add any extra paths that contain custom files (such as robots.txt or
# .htaccess) here, relative to this directory. These files are copied
# directly to the root of the documentation.
#html_extra_path = []
# If not '', a 'Last updated on:' timestamp is inserted at every page bottom,
# using the given strftime format.
#html_last_updated_fmt = '%b %d, %Y'
# If true, SmartyPants will be used to convert quotes and dashes to
# typographically correct entities.
#html_use_smartypants = True
# Custom sidebar templates, maps document names to template names.
#html_sidebars = {}
# Additional templates that should be rendered to pages, maps page names to
# template names.
#html_additional_pages = {}
# If false, no module index is generated.
#html_domain_indices = True
# If false, no index is generated.
#html_use_index = True
# If true, the index is split into individual pages for each letter.
#html_split_index = False
# If true, links to the reST sources are added to the pages.
#html_show_sourcelink = True
# If true, "Created using Sphinx" is shown in the HTML footer. Default is True.
#html_show_sphinx = True
# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True.
#html_show_copyright = True
# If true, an OpenSearch description file will be output, and all pages will
# contain a <link> tag referring to it. The value of this option must be the
# base URL from which the finished HTML is served.
#html_use_opensearch = ''
# This is the file name suffix for HTML files (e.g. ".xhtml").
#html_file_suffix = None
# Output file base name for HTML help builder.
htmlhelp_basename = 'django-auditlogdoc'
# -- Options for LaTeX output ---------------------------------------------
latex_elements = {
# The paper size ('letterpaper' or 'a4paper').
#'papersize': 'letterpaper',
# The font size ('10pt', '11pt' or '12pt').
#'pointsize': '10pt',
# Additional stuff for the LaTeX preamble.
#'preamble': '',
}
# Grouping the document tree into LaTeX files. List of tuples
# (source start file, target name, title,
# author, documentclass [howto, manual, or own class]).
latex_documents = [
('index', 'django-auditlog.tex', u'django-auditlog Documentation',
u'Jan-Jelle Kester', 'manual'),
]
# The name of an image file (relative to this directory) to place at the top of
# the title page.
#latex_logo = None
# For "manual" documents, if this is true, then toplevel headings are parts,
# not chapters.
#latex_use_parts = False
# If true, show page references after internal links.
#latex_show_pagerefs = False
# If true, show URL addresses after external links.
#latex_show_urls = False
# Documents to append as an appendix to all manuals.
#latex_appendices = []
# If false, no module index is generated.
#latex_domain_indices = True
# -- Options for manual page output ---------------------------------------
# One entry per manual page. List of tuples
# (source start file, name, description, authors, manual section).
man_pages = [
('index', 'django-auditlog', u'django-auditlog Documentation',
[u'Jan-Jelle Kester'], 1)
]
# If true, show URL addresses after external links.
#man_show_urls = False
# -- Options for Texinfo output -------------------------------------------
# Grouping the document tree into Texinfo files. List of tuples
# (source start file, target name, title, author,
# dir menu entry, description, category)
texinfo_documents = [
('index', 'django-auditlog', u'django-auditlog Documentation',
u'Jan-Jelle Kester', 'django-auditlog', '',
'Miscellaneous'),
]
# Documents to append as an appendix to all manuals.
#texinfo_appendices = []
# If false, no module index is generated.
#texinfo_domain_indices = True
# How to display URL addresses: 'footnote', 'no', or 'inline'.
#texinfo_show_urls = 'footnote'
# If true, do not generate a @detailmenu in the "Top" node's menu.
#texinfo_no_detailmenu = False
# html_static_path = ['_static']

View file

@ -21,20 +21,15 @@ Contents
installation
usage
upgrade
internals
Contribute to Auditlog
----------------------
.. note::
Due to multiple reasons the development of Auditlog is not a priority for me at this moment. Therefore progress might
be slow. This does not mean that this project is abandoned! Community involvement in the form of pull requests is
very much appreciated. Also, if you like to take Auditlog to the next level and be a permanent contributor, please
contact the author. Contact information can be found via GitHub.
If you discovered a bug or want to improve the code, please submit an issue and/or pull request via GitHub.
Before submitting a new issue, please make sure there is no issue submitted that involves the same problem.
| GitHub repository: https://github.com/jjkester/django-auditlog
| Issues: https://github.com/jjkester/django-auditlog/issues
| GitHub repository: https://github.com/jazzband/django-auditlog
| Issues: https://github.com/jazzband/django-auditlog/issues

View file

@ -7,15 +7,15 @@ way to do this is by using the Python Package Index (PyPI). Simply run the follo
``pip install django-auditlog``
Instead of installing Auditlog via PyPI, you can also clone the Git repository or download the source code via GitHub.
The repository can be found at https://github.com/jjkester/django-auditlog/.
The repository can be found at https://github.com/jazzband/django-auditlog/.
**Requirements**
- Python 3.5 or higher
- Django 1.11 or higher
- Python 3.10 or higher
- Django 4.2, 5.0, 5.1, and 5.2
Auditlog is currently tested with Python 2.7 and 3.4 and Django 1.8, 1.9 and 1.10. The latest test report can be found
at https://travis-ci.org/jjkester/django-auditlog.
Auditlog is currently tested with Python 3.10+ and Django 4.2, 5.0, 5.1, and 5.2. The latest test report can be found
at https://github.com/jazzband/django-auditlog/actions.
Adding Auditlog to your Django application
------------------------------------------
@ -24,5 +24,5 @@ To use Auditlog in your application, just add ``'auditlog'`` to your project's `
``manage.py migrate`` to create/upgrade the necessary database structure.
If you want Auditlog to automatically set the actor for log entries you also need to enable the middleware by adding
``'auditlog.middleware.AuditlogMiddleware'`` to your ``MIDDLEWARE_CLASSES`` setting. Please check :doc:`usage` for more
``'auditlog.middleware.AuditlogMiddleware'`` to your ``MIDDLEWARE`` setting. Please check :doc:`usage` for more
information.

View file

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

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