mirror of
https://github.com/jazzband/django-auditlog.git
synced 2026-03-16 22:20:26 +00:00
Compare commits
82 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e3a2ec1a7 | ||
|
|
dfd5b79d2d | ||
|
|
4154560de3 | ||
|
|
3f255a02d9 | ||
|
|
6d170da5fc | ||
|
|
198c060c3b | ||
|
|
ede4d10164 | ||
|
|
aedb6ead39 | ||
|
|
fb762a054f | ||
|
|
dc636716b0 | ||
|
|
66125030a8 | ||
|
|
eb9eefd76f | ||
|
|
d02ed6b9e0 | ||
|
|
074e6aa145 | ||
|
|
03336f9ef2 | ||
|
|
7d13fd4ba8 | ||
|
|
0e58a9d2d5 | ||
|
|
8c9b7b4a6e | ||
|
|
b1b6f9f4dd | ||
|
|
d417f30142 | ||
|
|
bd03eb6199 | ||
|
|
74ba152a67 | ||
|
|
85056b73c3 | ||
|
|
d87b92923e | ||
|
|
3051d230b9 | ||
|
|
65ebec6663 | ||
|
|
4de16fbd40 | ||
|
|
572aeebec7 | ||
|
|
5379e1e5d0 | ||
|
|
a7c07a491d | ||
|
|
8003b069c9 | ||
|
|
9ef8cf2476 | ||
|
|
d4d9f287a6 | ||
|
|
0eff3e8404 | ||
|
|
ca5f0aedfd | ||
|
|
138e4fc948 | ||
|
|
8fe73932a0 | ||
|
|
6184ec8adb | ||
|
|
637e04c31e | ||
|
|
af78976e53 | ||
|
|
3a58e0a999 | ||
|
|
b640df67a3 | ||
|
|
ecdefde9ed | ||
|
|
3f7f005377 | ||
|
|
d1e7566668 | ||
|
|
df185e32db | ||
|
|
cbcaff3569 | ||
|
|
6d7e8c7968 | ||
|
|
6414b7aedb | ||
|
|
4c1d573981 | ||
|
|
939dd9b298 | ||
|
|
de650b09c7 | ||
|
|
856ee0ae04 | ||
|
|
c4907bcd52 | ||
|
|
fb3fac5cce | ||
|
|
602c760b4c | ||
|
|
3e540bff6f | ||
|
|
a27c045280 | ||
|
|
6e51997728 | ||
|
|
925f0dbaee | ||
|
|
8150d35aef | ||
|
|
5621777622 | ||
|
|
d4f99c2729 | ||
|
|
938e644177 | ||
|
|
b1ecc8f754 | ||
|
|
512cd28318 | ||
|
|
0634357119 | ||
|
|
a53a6facfe | ||
|
|
4c3ee0b36d | ||
|
|
22dcbc4d06 | ||
|
|
07a3a83828 | ||
|
|
2958f58acd | ||
|
|
5759020015 | ||
|
|
5bb701d821 | ||
|
|
2c0bd0fac6 | ||
|
|
92805e2084 | ||
|
|
d412b2b16a | ||
|
|
8af8011073 | ||
|
|
ba2c2e32be | ||
|
|
a652eebfaf | ||
|
|
1569051f48 | ||
|
|
b768dc74f6 |
72 changed files with 3769 additions and 447 deletions
30
.github/actions/setup-python-deps/action.yml
vendored
Normal file
30
.github/actions/setup-python-deps/action.yml
vendored
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
name: 'Setup Python and Dependencies'
|
||||
description: 'Common setup steps for Python and pip dependencies'
|
||||
|
||||
inputs:
|
||||
python-version:
|
||||
description: 'Python version to setup'
|
||||
required: true
|
||||
cache-key-prefix:
|
||||
description: 'Prefix for pip cache key'
|
||||
required: true
|
||||
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Set up Python ${{ inputs.python-version }}
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ inputs.python-version }}
|
||||
|
||||
- name: Cache pip
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ inputs.cache-key-prefix }}-${{ inputs.python-version }}-${{ hashFiles('**/pyproject.toml') }}
|
||||
|
||||
- name: Install Python dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install --upgrade tox tox-gh-actions
|
||||
10
.github/workflows/release.yml
vendored
10
.github/workflows/release.yml
vendored
|
|
@ -11,14 +11,14 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.8'
|
||||
python-version: '3.10'
|
||||
|
||||
- name: Get pip cache dir
|
||||
id: pip-cache
|
||||
|
|
@ -26,7 +26,7 @@ jobs:
|
|||
echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Cache
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ${{ steps.pip-cache.outputs.dir }}
|
||||
key: release-${{ hashFiles('**/setup.py') }}
|
||||
|
|
@ -36,7 +36,7 @@ jobs:
|
|||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install -U pip
|
||||
python -m pip install -U setuptools twine wheel
|
||||
python -m pip install -U setuptools==75.6.0 twine==6.0.1 wheel pkginfo
|
||||
|
||||
- name: Build package
|
||||
run: |
|
||||
|
|
|
|||
142
.github/workflows/test.yml
vendored
142
.github/workflows/test.yml
vendored
|
|
@ -3,67 +3,125 @@ name: Test
|
|||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
test-sqlite:
|
||||
name: SQLite • Python ${{ matrix.python-version }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 5
|
||||
matrix:
|
||||
python-version: ['3.8', '3.9', '3.10', '3.11', '3.12']
|
||||
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Python and dependencies
|
||||
uses: ./.github/actions/setup-python-deps
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache-key-prefix: sqlite3
|
||||
|
||||
- name: Run tests
|
||||
env:
|
||||
TEST_DB_BACKEND: sqlite3
|
||||
run: tox -v
|
||||
|
||||
- name: Upload coverage
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
name: SQLite • Python ${{ matrix.python-version }}
|
||||
|
||||
test-postgres:
|
||||
name: PostgreSQL • Python ${{ matrix.python-version }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:13
|
||||
image: postgres:15
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: postgres
|
||||
POSTGRES_DB: auditlog
|
||||
ports:
|
||||
- 5432/tcp
|
||||
- 5432/tcp
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
--health-retries 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Setup Python and dependencies
|
||||
uses: ./.github/actions/setup-python-deps
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache-key-prefix: postgresql
|
||||
|
||||
- name: Get pip cache dir
|
||||
id: pip-cache
|
||||
run: |
|
||||
echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT
|
||||
- name: Run tests
|
||||
env:
|
||||
TEST_DB_BACKEND: postgresql
|
||||
TEST_DB_HOST: localhost
|
||||
TEST_DB_USER: postgres
|
||||
TEST_DB_PASS: postgres
|
||||
TEST_DB_NAME: auditlog
|
||||
TEST_DB_PORT: ${{ job.services.postgres.ports[5432] }}
|
||||
|
||||
- name: Cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.pip-cache.outputs.dir }}
|
||||
key:
|
||||
-${{ matrix.python-version }}-v1-${{ hashFiles('**/setup.py') }}
|
||||
restore-keys: |
|
||||
-${{ matrix.python-version }}-v1-
|
||||
run: tox -v
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install --upgrade tox tox-gh-actions
|
||||
- name: Upload coverage
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
name: PostgreSQL • Python ${{ matrix.python-version }}
|
||||
|
||||
- name: Tox tests
|
||||
run: |
|
||||
tox -v
|
||||
env:
|
||||
TEST_DB_HOST: localhost
|
||||
TEST_DB_USER: postgres
|
||||
TEST_DB_PASS: postgres
|
||||
TEST_DB_NAME: postgres
|
||||
TEST_DB_PORT: ${{ job.services.postgres.ports[5432] }}
|
||||
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: Upload coverage
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
name: Python ${{ matrix.python-version }}
|
||||
- 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 }}
|
||||
|
|
|
|||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -48,7 +48,6 @@ coverage.xml
|
|||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
|
|
@ -80,3 +79,4 @@ venv.bak/
|
|||
|
||||
### JetBrains
|
||||
.idea/
|
||||
.vscode/
|
||||
|
|
|
|||
|
|
@ -1,29 +1,29 @@
|
|||
---
|
||||
repos:
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 24.3.0
|
||||
- repo: https://github.com/psf/black-pre-commit-mirror
|
||||
rev: 26.3.0
|
||||
hooks:
|
||||
- id: black
|
||||
language_version: python3.8
|
||||
language_version: python3.10
|
||||
args:
|
||||
- "--target-version"
|
||||
- "py38"
|
||||
- "py310"
|
||||
- repo: https://github.com/PyCQA/flake8
|
||||
rev: "7.0.0"
|
||||
rev: "7.3.0"
|
||||
hooks:
|
||||
- id: flake8
|
||||
args: ["--max-line-length", "110"]
|
||||
- repo: https://github.com/PyCQA/isort
|
||||
rev: 5.13.2
|
||||
rev: 8.0.1
|
||||
hooks:
|
||||
- id: isort
|
||||
- repo: https://github.com/asottile/pyupgrade
|
||||
rev: v3.15.2
|
||||
rev: v3.21.2
|
||||
hooks:
|
||||
- id: pyupgrade
|
||||
args: [--py38-plus]
|
||||
args: [--py310-plus]
|
||||
- repo: https://github.com/adamchainz/django-upgrade
|
||||
rev: 1.16.0
|
||||
rev: 1.30.0
|
||||
hooks:
|
||||
- id: django-upgrade
|
||||
args: [--target-version, "3.2"]
|
||||
args: [--target-version, "4.2"]
|
||||
|
|
|
|||
84
CHANGELOG.md
84
CHANGELOG.md
|
|
@ -2,6 +2,90 @@
|
|||
|
||||
## 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
|
||||
|
|
|
|||
3
MANIFEST.in
Normal file
3
MANIFEST.in
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
recursive-include auditlog/templates *
|
||||
recursive-include auditlog/static *
|
||||
recursive-include auditlog/locale *
|
||||
42
Makefile
Normal file
42
Makefile
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
# Django Auditlog Makefile
|
||||
|
||||
# Default target shows help
|
||||
.DEFAULT_GOAL := help
|
||||
.PHONY: help install test makemessages compilemessages create-locale i18n clean
|
||||
|
||||
# Variables
|
||||
AUDITLOG_DIR := auditlog
|
||||
|
||||
install: ## Install dependencies
|
||||
pip install -e .
|
||||
|
||||
test: ## Run tests
|
||||
./runtests.sh
|
||||
|
||||
makemessages: ## Extract translatable strings and create/update .po files for all languages
|
||||
cd $(AUDITLOG_DIR) && \
|
||||
django-admin makemessages --add-location=file -a --ignore=__pycache__ --ignore=migrations
|
||||
|
||||
compilemessages: ## Compile all translation files (.po to .mo)
|
||||
cd $(AUDITLOG_DIR) && \
|
||||
django-admin compilemessages
|
||||
|
||||
create-locale: ## Create initial locale structure for a new language (requires LANG=<code>)
|
||||
@if [ -z "$(LANG)" ]; then \
|
||||
echo "Error: LANG parameter is required. Usage: make create-locale LANG=<language_code>"; \
|
||||
echo "Examples: make create-locale LANG=ko, make create-locale LANG=ja"; \
|
||||
exit 1; \
|
||||
fi
|
||||
mkdir -p $(AUDITLOG_DIR)/locale/$(LANG)/LC_MESSAGES
|
||||
cd $(AUDITLOG_DIR) && \
|
||||
django-admin makemessages --add-location=file -l $(LANG) --ignore=__pycache__ --ignore=migrations
|
||||
|
||||
i18n: makemessages compilemessages ## Full i18n workflow: extract strings, compile messages
|
||||
|
||||
clean: ## Clean compiled translation files (.mo files)
|
||||
find $(AUDITLOG_DIR)/locale -name "*.mo" -delete
|
||||
|
||||
help: ## Help message for targets
|
||||
@echo "Available commands:"
|
||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
|
||||
| awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
|
||||
|
|
@ -1,3 +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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -4,13 +4,16 @@ 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
|
||||
from auditlog.models import LogEntry
|
||||
|
||||
LogEntry = get_logentry_model()
|
||||
|
||||
|
||||
@admin.register(LogEntry)
|
||||
class LogEntryAdmin(admin.ModelAdmin, LogEntryAdminMixin):
|
||||
date_hierarchy = "timestamp"
|
||||
list_select_related = ["content_type", "actor"]
|
||||
list_display = [
|
||||
"created",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
from contextvars import ContextVar
|
||||
from typing import Optional
|
||||
|
||||
from django.conf import settings
|
||||
from django.http import HttpRequest
|
||||
|
|
@ -8,7 +7,7 @@ from django.utils.module_loading import import_string
|
|||
correlation_id = ContextVar("auditlog_correlation_id", default=None)
|
||||
|
||||
|
||||
def set_cid(request: Optional[HttpRequest] = None) -> None:
|
||||
def set_cid(request: HttpRequest | None = None) -> None:
|
||||
"""
|
||||
A function to read the cid from a request.
|
||||
If the header is not in the request, then we set it to `None`.
|
||||
|
|
@ -40,11 +39,11 @@ def set_cid(request: Optional[HttpRequest] = None) -> None:
|
|||
correlation_id.set(cid)
|
||||
|
||||
|
||||
def _get_cid() -> Optional[str]:
|
||||
def _get_cid() -> str | None:
|
||||
return correlation_id.get()
|
||||
|
||||
|
||||
def get_cid() -> Optional[str]:
|
||||
def get_cid() -> str | None:
|
||||
"""
|
||||
Calls the cid getter function based on `settings.AUDITLOG_CID_GETTER`
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,11 @@ 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
|
||||
|
|
@ -45,3 +50,29 @@ settings.AUDITLOG_USE_TEXT_CHANGES_IF_JSON_IS_NOT_PRESENT = getattr(
|
|||
settings.AUDITLOG_DISABLE_REMOTE_ADDR = getattr(
|
||||
settings, "AUDITLOG_DISABLE_REMOTE_ADDR", False
|
||||
)
|
||||
|
||||
# Number of characters at which changes_display_dict property should be shown
|
||||
settings.AUDITLOG_CHANGE_DISPLAY_TRUNCATE_LENGTH = getattr(
|
||||
settings, "AUDITLOG_CHANGE_DISPLAY_TRUNCATE_LENGTH", 140
|
||||
)
|
||||
|
||||
# Use pure JSON for changes field
|
||||
settings.AUDITLOG_STORE_JSON_CHANGES = getattr(
|
||||
settings, "AUDITLOG_STORE_JSON_CHANGES", False
|
||||
)
|
||||
|
||||
settings.AUDITLOG_MASK_CALLABLE = getattr(settings, "AUDITLOG_MASK_CALLABLE", None)
|
||||
|
||||
settings.AUDITLOG_LOGENTRY_MODEL = getattr(
|
||||
settings, "AUDITLOG_LOGENTRY_MODEL", "auditlog.LogEntry"
|
||||
)
|
||||
|
||||
# Use base model managers instead of default model managers
|
||||
settings.AUDITLOG_USE_BASE_MANAGER = getattr(
|
||||
settings, "AUDITLOG_USE_BASE_MANAGER", False
|
||||
)
|
||||
|
||||
# Use string representation of referenced object in foreign key changes instead of its primary key
|
||||
settings.AUDITLOG_USE_FK_STRING_REPRESENTATION = getattr(
|
||||
settings, "AUDITLOG_USE_FK_STRING_REPRESENTATION", False
|
||||
)
|
||||
|
|
|
|||
|
|
@ -6,26 +6,41 @@ from functools import partial
|
|||
from django.contrib.auth import get_user_model
|
||||
from django.db.models.signals import pre_save
|
||||
|
||||
from auditlog.models import LogEntry
|
||||
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):
|
||||
"""Connect a signal receiver with current user attached."""
|
||||
# Initialize thread local storage
|
||||
def set_actor(actor, remote_addr=None, remote_port=None):
|
||||
context_data = {
|
||||
"signal_duid": ("set_actor", time.time()),
|
||||
"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_actor = partial(_set_actor, user=actor, signal_duid=context_data["signal_duid"])
|
||||
set_extra_data = partial(
|
||||
_set_extra_data,
|
||||
signal_duid=context_data["signal_duid"],
|
||||
)
|
||||
pre_save.connect(
|
||||
set_actor,
|
||||
set_extra_data,
|
||||
sender=LogEntry,
|
||||
dispatch_uid=context_data["signal_duid"],
|
||||
weak=False,
|
||||
|
|
@ -42,11 +57,26 @@ def set_actor(actor, remote_addr=None):
|
|||
pre_save.disconnect(sender=LogEntry, dispatch_uid=auditlog["signal_duid"])
|
||||
|
||||
|
||||
def _set_actor(user, sender, instance, signal_duid, **kwargs):
|
||||
def _set_actor(auditlog, instance, sender):
|
||||
LogEntry = get_logentry_model()
|
||||
auth_user_model = get_user_model()
|
||||
if "actor" in auditlog:
|
||||
actor = auditlog.get("actor")
|
||||
if (
|
||||
sender == LogEntry
|
||||
and isinstance(actor, auth_user_model)
|
||||
and instance.actor is None
|
||||
):
|
||||
instance.actor = actor
|
||||
instance.actor_email = getattr(actor, "email", None)
|
||||
|
||||
|
||||
def _set_extra_data(sender, instance, signal_duid, **kwargs):
|
||||
"""Signal receiver with extra 'user' and 'signal_duid' kwargs.
|
||||
|
||||
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:
|
||||
|
|
@ -54,15 +84,15 @@ def _set_actor(user, sender, instance, signal_duid, **kwargs):
|
|||
else:
|
||||
if signal_duid != auditlog["signal_duid"]:
|
||||
return
|
||||
auth_user_model = get_user_model()
|
||||
if (
|
||||
sender == LogEntry
|
||||
and isinstance(user, auth_user_model)
|
||||
and instance.actor is None
|
||||
):
|
||||
instance.actor = user
|
||||
|
||||
instance.remote_addr = auditlog["remote_addr"]
|
||||
_set_actor(auditlog, instance, sender)
|
||||
|
||||
for key in auditlog:
|
||||
if key != "actor" and hasattr(LogEntry, key):
|
||||
if callable(auditlog[key]):
|
||||
setattr(instance, key, auditlog[key]())
|
||||
else:
|
||||
setattr(instance, key, auditlog[key])
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
|
|
|
|||
137
auditlog/diff.py
137
auditlog/diff.py
|
|
@ -1,12 +1,15 @@
|
|||
import json
|
||||
from collections.abc import Callable
|
||||
from datetime import timezone
|
||||
from typing import Optional
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist
|
||||
from django.db.models import NOT_PROVIDED, DateTimeField, ForeignKey, JSONField, Model
|
||||
from django.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):
|
||||
|
|
@ -20,7 +23,6 @@ def track_field(field):
|
|||
:return: Whether the given field should be tracked.
|
||||
:rtype: bool
|
||||
"""
|
||||
from auditlog.models import LogEntry
|
||||
|
||||
# Do not track many to many relations
|
||||
if field.many_to_many:
|
||||
|
|
@ -29,7 +31,7 @@ def track_field(field):
|
|||
# Do not track relations to LogEntry
|
||||
if (
|
||||
getattr(field, "remote_field", None) is not None
|
||||
and field.remote_field.model == LogEntry
|
||||
and field.remote_field.model == get_logentry_model()
|
||||
):
|
||||
return False
|
||||
|
||||
|
|
@ -51,7 +53,7 @@ def get_fields_in_model(instance):
|
|||
return [f for f in instance._meta.get_fields() if track_field(f)]
|
||||
|
||||
|
||||
def get_field_value(obj, field):
|
||||
def get_field_value(obj, field, use_json_for_changes=False):
|
||||
"""
|
||||
Gets the value of a given model instance field.
|
||||
|
||||
|
|
@ -62,11 +64,35 @@ def get_field_value(obj, field):
|
|||
: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 = field.to_python(getattr(obj, field.name, None))
|
||||
value = getattr(obj, field.name)
|
||||
try:
|
||||
value = field.to_python(value)
|
||||
except TypeError:
|
||||
return value
|
||||
if (
|
||||
value is not None
|
||||
and settings.USE_TZ
|
||||
|
|
@ -74,26 +100,65 @@ def get_field_value(obj, field):
|
|||
):
|
||||
value = django_timezone.make_naive(value, timezone=timezone.utc)
|
||||
elif isinstance(field, JSONField):
|
||||
value = field.to_python(getattr(obj, field.name, None))
|
||||
value = json.dumps(value, sort_keys=True, cls=field.encoder)
|
||||
elif (field.one_to_one or field.many_to_one) and hasattr(field, "rel_class"):
|
||||
value = smart_str(
|
||||
getattr(obj, field.get_attname(), None), strings_only=True
|
||||
)
|
||||
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 = smart_str(getattr(obj, field.name, None))
|
||||
if type(value).__name__ == "__proxy__":
|
||||
value = str(value)
|
||||
except ObjectDoesNotExist:
|
||||
value = (
|
||||
field.default
|
||||
if getattr(field, "default", NOT_PROVIDED) is not NOT_PROVIDED
|
||||
else None
|
||||
)
|
||||
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.
|
||||
|
|
@ -108,7 +173,10 @@ def mask_str(value: str) -> str:
|
|||
|
||||
|
||||
def model_instance_diff(
|
||||
old: Optional[Model], new: Optional[Model], fields_to_check=None
|
||||
old: Model | None,
|
||||
new: Model | None,
|
||||
fields_to_check=None,
|
||||
use_json_for_changes=False,
|
||||
):
|
||||
"""
|
||||
Calculates the differences between two model instances. One of the instances may be ``None``
|
||||
|
|
@ -121,6 +189,8 @@ def model_instance_diff(
|
|||
: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.
|
||||
|
|
@ -182,17 +252,30 @@ def model_instance_diff(
|
|||
fields = filtered_fields
|
||||
|
||||
for field in fields:
|
||||
old_value = get_field_value(old, field)
|
||||
new_value = get_field_value(new, field)
|
||||
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_str(smart_str(old_value)),
|
||||
mask_str(smart_str(new_value)),
|
||||
mask_func(smart_str(old_value)),
|
||||
mask_func(smart_str(new_value)),
|
||||
)
|
||||
else:
|
||||
diff[field.name] = (smart_str(old_value), smart_str(new_value))
|
||||
if not use_json_for_changes:
|
||||
diff[field.name] = (smart_str(old_value), smart_str(new_value))
|
||||
else:
|
||||
# TODO: should we handle the case where the value is a django Model specifically?
|
||||
# for example, could create a list of ids for ManyToMany fields
|
||||
|
||||
# this maintains the behavior of the original code
|
||||
if not is_primitive(old_value):
|
||||
old_value = smart_str(old_value)
|
||||
if not is_primitive(new_value):
|
||||
new_value = smart_str(new_value)
|
||||
diff[field.name] = (old_value, new_value)
|
||||
|
||||
if len(diff) == 0:
|
||||
diff = None
|
||||
|
|
|
|||
BIN
auditlog/locale/ja/LC_MESSAGES/django.mo
Normal file
BIN
auditlog/locale/ja/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
192
auditlog/locale/ja/LC_MESSAGES/django.po
Normal file
192
auditlog/locale/ja/LC_MESSAGES/django.po
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
# Django Auditlog Japanese Translation
|
||||
# Copyright (C) 2025 Django Auditlog Contributors
|
||||
# This file is distributed under the same license as the django-auditlog package.
|
||||
# Youngkwang Yang <me@youngkwang.dev>, 2025.
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: django-auditlog\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-09-28 03:43+0900\n"
|
||||
"PO-Revision-Date: 2025-09-28 03:16+0900\n"
|
||||
"Last-Translator: Youngkwang Yang <me@youngkwang.dev>\n"
|
||||
"Language-Team: Japanese <ja@li.org>\n"
|
||||
"Language: ja\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=1; plural=0;\n"
|
||||
|
||||
#: admin.py mixins.py
|
||||
msgid "Changes"
|
||||
msgstr "変更"
|
||||
|
||||
#: apps.py mixins.py templates/auditlog/object_history.html
|
||||
msgid "Audit log"
|
||||
msgstr "監査ログ"
|
||||
|
||||
#: filters.py
|
||||
msgid "Resource Type"
|
||||
msgstr "リソースタイプ"
|
||||
|
||||
#: filters.py models.py
|
||||
msgid "Correlation ID"
|
||||
msgstr "Correlation ID"
|
||||
|
||||
#: mixins.py
|
||||
msgid "Click to filter by records with this correlation id"
|
||||
msgstr "このCorrelation IDでレコードをフィルタするにはクリックしてください"
|
||||
|
||||
#: mixins.py
|
||||
msgid "Created"
|
||||
msgstr "作成済み"
|
||||
|
||||
#: mixins.py
|
||||
msgid "User"
|
||||
msgstr "ユーザー"
|
||||
|
||||
#: mixins.py
|
||||
msgid "Resource"
|
||||
msgstr "リソース"
|
||||
|
||||
#: mixins.py
|
||||
#, python-format
|
||||
msgid "Audit log: %s"
|
||||
msgstr "監査ログ:%s"
|
||||
|
||||
#: mixins.py
|
||||
msgid "View"
|
||||
msgstr "表示"
|
||||
|
||||
#: models.py
|
||||
msgid "create"
|
||||
msgstr "作成"
|
||||
|
||||
#: models.py
|
||||
msgid "update"
|
||||
msgstr "更新"
|
||||
|
||||
#: models.py
|
||||
msgid "delete"
|
||||
msgstr "削除"
|
||||
|
||||
#: models.py
|
||||
msgid "access"
|
||||
msgstr "アクセス"
|
||||
|
||||
#: models.py
|
||||
msgid "content type"
|
||||
msgstr "コンテンツタイプ"
|
||||
|
||||
#: models.py
|
||||
msgid "object pk"
|
||||
msgstr "オブジェクトPK"
|
||||
|
||||
#: models.py
|
||||
msgid "object id"
|
||||
msgstr "オブジェクトID"
|
||||
|
||||
#: models.py
|
||||
msgid "object representation"
|
||||
msgstr "オブジェクト表現"
|
||||
|
||||
#: models.py
|
||||
msgid "action"
|
||||
msgstr "アクション"
|
||||
|
||||
#: models.py
|
||||
msgid "change message"
|
||||
msgstr "変更メッセージ"
|
||||
|
||||
#: models.py
|
||||
msgid "actor"
|
||||
msgstr "アクター"
|
||||
|
||||
#: models.py
|
||||
msgid "remote address"
|
||||
msgstr "リモートアドレス"
|
||||
|
||||
#: models.py
|
||||
msgid "remote port"
|
||||
msgstr "リモートポート"
|
||||
|
||||
#: models.py
|
||||
msgid "timestamp"
|
||||
msgstr "タイムスタンプ"
|
||||
|
||||
#: models.py
|
||||
msgid "additional data"
|
||||
msgstr "追加データ"
|
||||
|
||||
#: models.py
|
||||
msgid "actor email"
|
||||
msgstr "アクターメール"
|
||||
|
||||
#: models.py
|
||||
msgid "log entry"
|
||||
msgstr "ログエントリ"
|
||||
|
||||
#: models.py
|
||||
msgid "log entries"
|
||||
msgstr "ログエントリ"
|
||||
|
||||
#: models.py
|
||||
msgid "Created {repr:s}"
|
||||
msgstr "{repr:s}が作成されました"
|
||||
|
||||
#: models.py
|
||||
msgid "Updated {repr:s}"
|
||||
msgstr "{repr:s}が更新されました"
|
||||
|
||||
#: models.py
|
||||
msgid "Deleted {repr:s}"
|
||||
msgstr "{repr:s}が削除されました"
|
||||
|
||||
#: models.py
|
||||
msgid "Logged {repr:s}"
|
||||
msgstr "{repr:s}がログに記録されました"
|
||||
|
||||
#: render.py
|
||||
msgid "Field"
|
||||
msgstr "フィールド"
|
||||
|
||||
#: render.py
|
||||
msgid "From"
|
||||
msgstr "変更前の値"
|
||||
|
||||
#: render.py
|
||||
msgid "To"
|
||||
msgstr "変更後の値"
|
||||
|
||||
#: render.py
|
||||
msgid "Relationship"
|
||||
msgstr "関係"
|
||||
|
||||
#: render.py
|
||||
msgid "Action"
|
||||
msgstr "アクション"
|
||||
|
||||
#: render.py
|
||||
msgid "Objects"
|
||||
msgstr "オブジェクト一覧"
|
||||
|
||||
#: templates/auditlog/entry_detail.html
|
||||
msgid "system"
|
||||
msgstr "システム"
|
||||
|
||||
#: templates/auditlog/entry_detail.html
|
||||
msgid "No field changes"
|
||||
msgstr "フィールドの変更なし"
|
||||
|
||||
#: templates/auditlog/object_history.html
|
||||
msgid "Home"
|
||||
msgstr "ホーム"
|
||||
|
||||
#: templates/auditlog/object_history.html
|
||||
msgid "No log entries found."
|
||||
msgstr "ログエントリが見つかりません。"
|
||||
|
||||
#: templates/auditlog/pagination.html
|
||||
msgid "entry"
|
||||
msgid_plural "entries"
|
||||
msgstr[0] "ログエントリ"
|
||||
BIN
auditlog/locale/ko/LC_MESSAGES/django.mo
Normal file
BIN
auditlog/locale/ko/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
192
auditlog/locale/ko/LC_MESSAGES/django.po
Normal file
192
auditlog/locale/ko/LC_MESSAGES/django.po
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
# Django Auditlog Korean Translation
|
||||
# Copyright (C) 2025 Django Auditlog Contributors
|
||||
# This file is distributed under the same license as the django-auditlog package.
|
||||
# Youngkwang Yang <me@youngkwang.dev>, 2025.
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: django-auditlog\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-09-28 03:43+0900\n"
|
||||
"PO-Revision-Date: 2025-09-28 02:55+0900\n"
|
||||
"Last-Translator: Youngkwang Yang <me@youngkwang.dev>\n"
|
||||
"Language-Team: Korean <ko@li.org>\n"
|
||||
"Language: ko\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=1; plural=0;\n"
|
||||
|
||||
#: admin.py mixins.py
|
||||
msgid "Changes"
|
||||
msgstr "변경 사항"
|
||||
|
||||
#: apps.py mixins.py templates/auditlog/object_history.html
|
||||
msgid "Audit log"
|
||||
msgstr "감사 로그"
|
||||
|
||||
#: filters.py
|
||||
msgid "Resource Type"
|
||||
msgstr "리소스 타입"
|
||||
|
||||
#: filters.py models.py
|
||||
msgid "Correlation ID"
|
||||
msgstr "Correlation ID"
|
||||
|
||||
#: mixins.py
|
||||
msgid "Click to filter by records with this correlation id"
|
||||
msgstr "이 Correlation ID로 레코드를 필터링하려면 클릭하세요"
|
||||
|
||||
#: mixins.py
|
||||
msgid "Created"
|
||||
msgstr "생성됨"
|
||||
|
||||
#: mixins.py
|
||||
msgid "User"
|
||||
msgstr "사용자"
|
||||
|
||||
#: mixins.py
|
||||
msgid "Resource"
|
||||
msgstr "리소스"
|
||||
|
||||
#: mixins.py
|
||||
#, python-format
|
||||
msgid "Audit log: %s"
|
||||
msgstr "감사 로그: %s"
|
||||
|
||||
#: mixins.py
|
||||
msgid "View"
|
||||
msgstr "보기"
|
||||
|
||||
#: models.py
|
||||
msgid "create"
|
||||
msgstr "생성"
|
||||
|
||||
#: models.py
|
||||
msgid "update"
|
||||
msgstr "수정"
|
||||
|
||||
#: models.py
|
||||
msgid "delete"
|
||||
msgstr "삭제"
|
||||
|
||||
#: models.py
|
||||
msgid "access"
|
||||
msgstr "접근"
|
||||
|
||||
#: models.py
|
||||
msgid "content type"
|
||||
msgstr "콘텐츠 타입"
|
||||
|
||||
#: models.py
|
||||
msgid "object pk"
|
||||
msgstr "객체 PK"
|
||||
|
||||
#: models.py
|
||||
msgid "object id"
|
||||
msgstr "객체 ID"
|
||||
|
||||
#: models.py
|
||||
msgid "object representation"
|
||||
msgstr "객체 표현"
|
||||
|
||||
#: models.py
|
||||
msgid "action"
|
||||
msgstr "작업"
|
||||
|
||||
#: models.py
|
||||
msgid "change message"
|
||||
msgstr "변경 메시지"
|
||||
|
||||
#: models.py
|
||||
msgid "actor"
|
||||
msgstr "작업자"
|
||||
|
||||
#: models.py
|
||||
msgid "remote address"
|
||||
msgstr "원격 주소"
|
||||
|
||||
#: models.py
|
||||
msgid "remote port"
|
||||
msgstr "원격 포트"
|
||||
|
||||
#: models.py
|
||||
msgid "timestamp"
|
||||
msgstr "타임스탬프"
|
||||
|
||||
#: models.py
|
||||
msgid "additional data"
|
||||
msgstr "추가 데이터"
|
||||
|
||||
#: models.py
|
||||
msgid "actor email"
|
||||
msgstr "작업자 이메일"
|
||||
|
||||
#: models.py
|
||||
msgid "log entry"
|
||||
msgstr "로그 항목"
|
||||
|
||||
#: models.py
|
||||
msgid "log entries"
|
||||
msgstr "로그 항목들"
|
||||
|
||||
#: models.py
|
||||
msgid "Created {repr:s}"
|
||||
msgstr "{repr:s}이(가) 생성됨"
|
||||
|
||||
#: models.py
|
||||
msgid "Updated {repr:s}"
|
||||
msgstr "{repr:s}이(가) 수정됨"
|
||||
|
||||
#: models.py
|
||||
msgid "Deleted {repr:s}"
|
||||
msgstr "{repr:s}이(가) 삭제됨"
|
||||
|
||||
#: models.py
|
||||
msgid "Logged {repr:s}"
|
||||
msgstr "{repr:s}이(가) 기록됨"
|
||||
|
||||
#: render.py
|
||||
msgid "Field"
|
||||
msgstr "필드"
|
||||
|
||||
#: render.py
|
||||
msgid "From"
|
||||
msgstr "변경 전"
|
||||
|
||||
#: render.py
|
||||
msgid "To"
|
||||
msgstr "변경 후"
|
||||
|
||||
#: render.py
|
||||
msgid "Relationship"
|
||||
msgstr "관계"
|
||||
|
||||
#: render.py
|
||||
msgid "Action"
|
||||
msgstr "작업"
|
||||
|
||||
#: render.py
|
||||
msgid "Objects"
|
||||
msgstr "객체"
|
||||
|
||||
#: templates/auditlog/entry_detail.html
|
||||
msgid "system"
|
||||
msgstr "시스템"
|
||||
|
||||
#: templates/auditlog/entry_detail.html
|
||||
msgid "No field changes"
|
||||
msgstr "필드 변경 사항 없음"
|
||||
|
||||
#: templates/auditlog/object_history.html
|
||||
msgid "Home"
|
||||
msgstr "홈"
|
||||
|
||||
#: templates/auditlog/object_history.html
|
||||
msgid "No log entries found."
|
||||
msgstr "로그 항목을 찾을 수 없습니다."
|
||||
|
||||
#: templates/auditlog/pagination.html
|
||||
msgid "entry"
|
||||
msgid_plural "entries"
|
||||
msgstr[0] "항목"
|
||||
|
|
@ -1,8 +1,11 @@
|
|||
import datetime
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import connection
|
||||
|
||||
from auditlog.models import LogEntry
|
||||
from auditlog import get_logentry_model
|
||||
|
||||
LogEntry = get_logentry_model()
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
|
|
@ -25,11 +28,24 @@ class Command(BaseCommand):
|
|||
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."
|
||||
|
|
@ -42,11 +58,39 @@ class Command(BaseCommand):
|
|||
)
|
||||
answer = response == "y"
|
||||
|
||||
if answer:
|
||||
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:
|
||||
self.stdout.write("Aborted.")
|
||||
database_vendor = connection.vendor
|
||||
database_display_name = connection.display_name
|
||||
table_name = LogEntry._meta.db_table
|
||||
if not TruncateQuery.support_truncate_statement(database_vendor):
|
||||
self.stdout.write(
|
||||
"Database %s does not support truncate statement."
|
||||
% database_display_name
|
||||
)
|
||||
return
|
||||
with connection.cursor() as cursor:
|
||||
query = TruncateQuery.to_sql(table_name)
|
||||
cursor.execute(query)
|
||||
self.stdout.write("Truncated log entry table.")
|
||||
|
||||
|
||||
class TruncateQuery:
|
||||
SUPPORTED_VENDORS = ("postgresql", "mysql", "oracle", "microsoft")
|
||||
|
||||
@classmethod
|
||||
def support_truncate_statement(cls, database_vendor) -> bool:
|
||||
return database_vendor in cls.SUPPORTED_VENDORS
|
||||
|
||||
@staticmethod
|
||||
def to_sql(table_name) -> str:
|
||||
return f"TRUNCATE TABLE {table_name};"
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@ from django.conf import settings
|
|||
from django.core.management import CommandError, CommandParser
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from auditlog.models import LogEntry
|
||||
from auditlog import get_logentry_model
|
||||
|
||||
LogEntry = get_logentry_model()
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
|
|
@ -124,9 +126,13 @@ class Command(BaseCommand):
|
|||
|
||||
def postgres():
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
'UPDATE auditlog_logentry SET changes="changes_text"::jsonb'
|
||||
)
|
||||
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":
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ from django.conf import settings
|
|||
from django.contrib.auth import get_user_model
|
||||
|
||||
from auditlog.cid import set_cid
|
||||
from auditlog.context import set_actor
|
||||
from auditlog.context import set_extra_data
|
||||
|
||||
|
||||
class AuditlogMiddleware:
|
||||
|
|
@ -36,6 +36,17 @@ class AuditlogMiddleware:
|
|||
|
||||
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)
|
||||
|
|
@ -43,11 +54,17 @@ class AuditlogMiddleware:
|
|||
return user
|
||||
return None
|
||||
|
||||
def __call__(self, request):
|
||||
remote_addr = self._get_remote_addr(request)
|
||||
user = self._get_actor(request)
|
||||
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_actor(actor=user, remote_addr=remote_addr):
|
||||
with set_extra_data(context_data=self.get_extra_data(request)):
|
||||
return self.get_response(request)
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
# Generated by Django 4.0 on 2022-08-04 15:41
|
||||
from typing import List
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def two_step_migrations() -> List:
|
||||
def two_step_migrations() -> list:
|
||||
if settings.AUDITLOG_TWO_STEP_MIGRATION:
|
||||
return [
|
||||
migrations.RenameField(
|
||||
|
|
|
|||
17
auditlog/migrations/0016_logentry_remote_port.py
Normal file
17
auditlog/migrations/0016_logentry_remote_port.py
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("auditlog", "0015_alter_logentry_changes"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="logentry",
|
||||
name="remote_port",
|
||||
field=models.PositiveIntegerField(
|
||||
blank=True, null=True, verbose_name="remote port"
|
||||
),
|
||||
),
|
||||
]
|
||||
20
auditlog/migrations/0017_add_actor_email.py
Normal file
20
auditlog/migrations/0017_add_actor_email.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("auditlog", "0016_logentry_remote_port"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="logentry",
|
||||
name="actor_email",
|
||||
field=models.CharField(
|
||||
null=True,
|
||||
verbose_name="actor email",
|
||||
blank=True,
|
||||
max_length=254,
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -1,19 +1,25 @@
|
|||
from urllib.parse import unquote
|
||||
|
||||
from django import urls as urlresolvers
|
||||
from django.conf import settings
|
||||
from django.contrib import admin
|
||||
from django.core.exceptions import FieldDoesNotExist
|
||||
from django.forms.utils import pretty_name
|
||||
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, format_html_join
|
||||
from django.utils.safestring import mark_safe
|
||||
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.models import LogEntry
|
||||
from auditlog.registry import auditlog
|
||||
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
|
||||
|
||||
|
||||
|
|
@ -68,55 +74,7 @@ class LogEntryAdminMixin:
|
|||
|
||||
@admin.display(description=_("Changes"))
|
||||
def msg(self, obj):
|
||||
changes = obj.changes_dict
|
||||
|
||||
atom_changes = {}
|
||||
m2m_changes = {}
|
||||
|
||||
for field, change in changes.items():
|
||||
if isinstance(change, dict):
|
||||
assert (
|
||||
change["type"] == "m2m"
|
||||
), "Only m2m operations are expected to produce dict changes now"
|
||||
m2m_changes[field] = change
|
||||
else:
|
||||
atom_changes[field] = change
|
||||
|
||||
msg = []
|
||||
|
||||
if atom_changes:
|
||||
msg.append("<table>")
|
||||
msg.append(self._format_header("#", "Field", "From", "To"))
|
||||
for i, (field, change) in enumerate(sorted(atom_changes.items()), 1):
|
||||
value = [i, self.field_verbose_name(obj, field)] + (
|
||||
["***", "***"] if field == "password" else change
|
||||
)
|
||||
msg.append(self._format_line(*value))
|
||||
msg.append("</table>")
|
||||
|
||||
if m2m_changes:
|
||||
msg.append("<table>")
|
||||
msg.append(self._format_header("#", "Relationship", "Action", "Objects"))
|
||||
for i, (field, change) in enumerate(sorted(m2m_changes.items()), 1):
|
||||
change_html = format_html_join(
|
||||
mark_safe("<br>"),
|
||||
"{}",
|
||||
[(value,) for value in change["objects"]],
|
||||
)
|
||||
|
||||
msg.append(
|
||||
format_html(
|
||||
"<tr><td>{}</td><td>{}</td><td>{}</td><td>{}</td></tr>",
|
||||
i,
|
||||
self.field_verbose_name(obj, field),
|
||||
change["operation"],
|
||||
change_html,
|
||||
)
|
||||
)
|
||||
|
||||
msg.append("</table>")
|
||||
|
||||
return mark_safe("".join(msg))
|
||||
return render_logentry_changes_html(obj)
|
||||
|
||||
@admin.display(description="Correlation ID")
|
||||
def cid_url(self, obj):
|
||||
|
|
@ -127,43 +85,95 @@ class LogEntryAdminMixin:
|
|||
'<a href="{}" title="{}">{}</a>', url, self.CID_TITLE, cid
|
||||
)
|
||||
|
||||
def _format_header(self, *labels):
|
||||
return format_html(
|
||||
"".join(["<tr>", "<th>{}</th>" * len(labels), "</tr>"]), *labels
|
||||
)
|
||||
|
||||
def _format_line(self, *values):
|
||||
return format_html(
|
||||
"".join(["<tr>", "<td>{}</td>" * len(values), "</tr>"]), *values
|
||||
)
|
||||
|
||||
def field_verbose_name(self, obj, field_name: str):
|
||||
model = obj.content_type.model_class()
|
||||
if model is None:
|
||||
return field_name
|
||||
try:
|
||||
model_fields = auditlog.get_model_fields(model._meta.model)
|
||||
mapping_field_name = model_fields["mapping_fields"].get(field_name)
|
||||
if mapping_field_name:
|
||||
return mapping_field_name
|
||||
except KeyError:
|
||||
# Model definition in auditlog was probably removed
|
||||
pass
|
||||
try:
|
||||
field = model._meta.get_field(field_name)
|
||||
return pretty_name(getattr(field, "verbose_name", field_name))
|
||||
except FieldDoesNotExist:
|
||||
return pretty_name(field_name)
|
||||
|
||||
def _add_query_parameter(self, key: str, value: str):
|
||||
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"))
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import ast
|
||||
import contextlib
|
||||
import json
|
||||
from collections.abc import Callable
|
||||
from copy import deepcopy
|
||||
from datetime import timezone
|
||||
from typing import Any, Callable, Dict, List, Union
|
||||
from typing import Any
|
||||
|
||||
from dateutil import parser
|
||||
from dateutil.tz import gettz
|
||||
|
|
@ -23,7 +24,8 @@ from django.utils import timezone as django_timezone
|
|||
from django.utils.encoding import smart_str
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from auditlog.diff import mask_str
|
||||
from auditlog import get_logentry_model
|
||||
from auditlog.diff import get_mask_function
|
||||
|
||||
DEFAULT_OBJECT_REPR = "<error forming object repr>"
|
||||
|
||||
|
|
@ -213,12 +215,13 @@ class LogEntryManager(models.Manager):
|
|||
:type instance: Model
|
||||
:return: The primary key value of the given model instance.
|
||||
"""
|
||||
pk_field = instance._meta.pk.name
|
||||
# Should be equivalent to `instance.pk`.
|
||||
pk_field = instance._meta.pk.attname
|
||||
pk = getattr(instance, pk_field, None)
|
||||
|
||||
# Check to make sure that we got a pk not a model object.
|
||||
if isinstance(pk, models.Model):
|
||||
pk = self._get_pk_value(pk)
|
||||
# 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):
|
||||
|
|
@ -246,7 +249,7 @@ class LogEntryManager(models.Manager):
|
|||
|
||||
mask_fields = model_fields["mask_fields"]
|
||||
if mask_fields:
|
||||
data = self._mask_serialized_fields(data, mask_fields)
|
||||
data = self._mask_serialized_fields(data, mask_fields, model_fields)
|
||||
|
||||
return data
|
||||
|
||||
|
|
@ -274,8 +277,8 @@ class LogEntryManager(models.Manager):
|
|||
return instance_copy
|
||||
|
||||
def _get_applicable_model_fields(
|
||||
self, instance, model_fields: Dict[str, List[str]]
|
||||
) -> List[str]:
|
||||
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]
|
||||
|
|
@ -286,14 +289,15 @@ class LogEntryManager(models.Manager):
|
|||
return list(set(include_fields or all_field_names).difference(exclude_fields))
|
||||
|
||||
def _mask_serialized_fields(
|
||||
self, data: Dict[str, Any], mask_fields: List[str]
|
||||
) -> Dict[str, Any]:
|
||||
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_str(value)
|
||||
masked_field_data[key] = mask_func(value)
|
||||
else:
|
||||
masked_field_data[key] = value
|
||||
|
||||
|
|
@ -301,7 +305,7 @@ class LogEntryManager(models.Manager):
|
|||
return data
|
||||
|
||||
|
||||
class LogEntry(models.Model):
|
||||
class AbstractLogEntry(models.Model):
|
||||
"""
|
||||
Represents an entry in the audit log. The content type is saved along with the textual and numeric
|
||||
(if available) primary key, as well as the textual representation of the object when it was saved.
|
||||
|
|
@ -372,6 +376,9 @@ class LogEntry(models.Model):
|
|||
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,
|
||||
|
|
@ -380,10 +387,14 @@ class LogEntry(models.Model):
|
|||
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")
|
||||
|
|
@ -416,21 +427,29 @@ class LogEntry(models.Model):
|
|||
not satisfying, please use :py:func:`LogEntry.changes_dict` and format the string yourself.
|
||||
|
||||
:param colon: The string to place between the field name and the values.
|
||||
:param arrow: The string to place between each old and new value.
|
||||
:param 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, values in self.changes_dict.items():
|
||||
substring = "{field_name:s}{colon:s}{old:s}{arrow:s}{new:s}".format(
|
||||
field_name=field,
|
||||
colon=colon,
|
||||
old=values[0],
|
||||
arrow=arrow,
|
||||
new=values[1],
|
||||
)
|
||||
substrings.append(substring)
|
||||
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)
|
||||
|
||||
|
|
@ -447,9 +466,17 @@ class LogEntry(models.Model):
|
|||
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 self.changes_dict.items():
|
||||
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)
|
||||
|
|
@ -502,9 +529,9 @@ class LogEntry(models.Model):
|
|||
elif field_type in ["ForeignKey", "OneToOneField"]:
|
||||
value = self._get_changes_display_for_fk_field(field, value)
|
||||
|
||||
# check if length is longer than 140 and truncate with ellipsis
|
||||
if len(value) > 140:
|
||||
value = f"{value[:140]}..."
|
||||
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)
|
||||
|
||||
|
|
@ -518,7 +545,7 @@ class LogEntry(models.Model):
|
|||
return changes_display_dict
|
||||
|
||||
def _get_changes_display_for_fk_field(
|
||||
self, field: Union[models.ForeignKey, models.OneToOneField], value: Any
|
||||
self, field: models.ForeignKey | models.OneToOneField, value: Any
|
||||
) -> str:
|
||||
"""
|
||||
:return: A string representing a given FK value and the field to which it belongs
|
||||
|
|
@ -537,12 +564,19 @@ class LogEntry(models.Model):
|
|||
return value
|
||||
# Attempt to return the string representation of the object
|
||||
try:
|
||||
return smart_str(field.related_model.objects.get(pk=pk_value))
|
||||
related_model_manager = _get_manager_from_settings(field.related_model)
|
||||
|
||||
return smart_str(related_model_manager.get(pk=pk_value))
|
||||
# ObjectDoesNotExist will be raised if the object was deleted.
|
||||
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
|
||||
|
|
@ -563,7 +597,7 @@ class AuditlogHistoryField(GenericRelation):
|
|||
"""
|
||||
|
||||
def __init__(self, pk_indexable=True, delete_related=False, **kwargs):
|
||||
kwargs["to"] = LogEntry
|
||||
kwargs["to"] = get_logentry_model()
|
||||
|
||||
if pk_indexable:
|
||||
kwargs["object_id_field"] = "object_id"
|
||||
|
|
@ -591,8 +625,8 @@ class AuditlogHistoryField(GenericRelation):
|
|||
changes_func = None
|
||||
|
||||
|
||||
def _changes_func() -> Callable[[LogEntry], Dict]:
|
||||
def json_then_text(instance: LogEntry) -> Dict:
|
||||
def _changes_func() -> Callable[[LogEntry], dict]:
|
||||
def json_then_text(instance: LogEntry) -> dict:
|
||||
if instance.changes:
|
||||
return instance.changes
|
||||
elif instance.changes_text:
|
||||
|
|
@ -600,9 +634,22 @@ def _changes_func() -> Callable[[LogEntry], Dict]:
|
|||
return json.loads(instance.changes_text)
|
||||
return {}
|
||||
|
||||
def default(instance: LogEntry) -> Dict:
|
||||
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
|
||||
|
|
|
|||
|
|
@ -2,9 +2,10 @@ 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 LogEntry
|
||||
from auditlog.models import _get_manager_from_settings
|
||||
from auditlog.signals import post_log, pre_log
|
||||
|
||||
|
||||
|
|
@ -38,11 +39,12 @@ def log_create(sender, instance, created, **kwargs):
|
|||
"""
|
||||
if created:
|
||||
_create_log_entry(
|
||||
action=LogEntry.Action.CREATE,
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -53,16 +55,17 @@ def log_update(sender, instance, **kwargs):
|
|||
|
||||
Direct use is discouraged, connect your model through :py:func:`auditlog.registry.register` instead.
|
||||
"""
|
||||
if not instance._state.adding:
|
||||
if not instance._state.adding and instance.pk is not None:
|
||||
update_fields = kwargs.get("update_fields", None)
|
||||
old = sender.objects.filter(pk=instance.pk).first()
|
||||
old = _get_manager_from_settings(sender).filter(pk=instance.pk).first()
|
||||
_create_log_entry(
|
||||
action=LogEntry.Action.UPDATE,
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -75,11 +78,12 @@ def log_delete(sender, instance, **kwargs):
|
|||
"""
|
||||
if instance.pk is not None:
|
||||
_create_log_entry(
|
||||
action=LogEntry.Action.DELETE,
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -91,17 +95,25 @@ def log_access(sender, instance, **kwargs):
|
|||
"""
|
||||
if instance.pk is not None:
|
||||
_create_log_entry(
|
||||
action=LogEntry.Action.ACCESS,
|
||||
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
|
||||
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,
|
||||
|
|
@ -111,13 +123,17 @@ def _create_log_entry(
|
|||
|
||||
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
|
||||
diff_old,
|
||||
diff_new,
|
||||
fields_to_check=fields_to_check,
|
||||
use_json_for_changes=use_json_for_changes,
|
||||
)
|
||||
|
||||
if force_log or changes:
|
||||
|
|
@ -141,6 +157,7 @@ def _create_log_entry(
|
|||
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
|
||||
|
|
@ -154,11 +171,14 @@ def make_log_m2m_changes(field_name):
|
|||
"""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 = kwargs["model"].objects.all()
|
||||
changed_queryset = model_manager.all()
|
||||
else:
|
||||
changed_queryset = kwargs["model"].objects.filter(pk__in=kwargs["pk_set"])
|
||||
changed_queryset = model_manager.filter(pk__in=kwargs["pk_set"])
|
||||
|
||||
if action in ["post_add"]:
|
||||
LogEntry.objects.log_m2m_changes(
|
||||
|
|
|
|||
|
|
@ -1,16 +1,7 @@
|
|||
import copy
|
||||
from collections import defaultdict
|
||||
from typing import (
|
||||
Any,
|
||||
Callable,
|
||||
Collection,
|
||||
Dict,
|
||||
Iterable,
|
||||
List,
|
||||
Optional,
|
||||
Tuple,
|
||||
Union,
|
||||
)
|
||||
from collections.abc import Callable, Collection, Iterable
|
||||
from typing import Any
|
||||
|
||||
from django.apps import apps
|
||||
from django.db.models import ManyToManyField, Model
|
||||
|
|
@ -26,7 +17,7 @@ from django.db.models.signals import (
|
|||
from auditlog.conf import settings
|
||||
from auditlog.signals import accessed
|
||||
|
||||
DispatchUID = Tuple[int, int, int]
|
||||
DispatchUID = tuple[int, int, int]
|
||||
|
||||
|
||||
class AuditLogRegistrationError(Exception):
|
||||
|
|
@ -38,7 +29,7 @@ class AuditlogModelRegistry:
|
|||
A registry that keeps track of the models that use Auditlog to track changes.
|
||||
"""
|
||||
|
||||
DEFAULT_EXCLUDE_MODELS = ("auditlog.LogEntry", "admin.LogEntry")
|
||||
DEFAULT_EXCLUDE_MODELS = (settings.AUDITLOG_LOGENTRY_MODEL, "admin.LogEntry")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
|
|
@ -47,7 +38,7 @@ class AuditlogModelRegistry:
|
|||
delete: bool = True,
|
||||
access: bool = True,
|
||||
m2m: bool = True,
|
||||
custom: Optional[Dict[ModelSignal, Callable]] = None,
|
||||
custom: dict[ModelSignal, Callable] | None = None,
|
||||
):
|
||||
from auditlog.receivers import log_access, log_create, log_delete, log_update
|
||||
|
||||
|
|
@ -71,13 +62,14 @@ class AuditlogModelRegistry:
|
|||
def register(
|
||||
self,
|
||||
model: ModelBase = None,
|
||||
include_fields: Optional[List[str]] = None,
|
||||
exclude_fields: Optional[List[str]] = None,
|
||||
mapping_fields: Optional[Dict[str, str]] = None,
|
||||
mask_fields: Optional[List[str]] = None,
|
||||
m2m_fields: Optional[Collection[str]] = None,
|
||||
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: Optional[Dict[str, Any]] = None,
|
||||
serialize_kwargs: dict[str, Any] | None = None,
|
||||
serialize_auditlog_fields_only: bool = False,
|
||||
):
|
||||
"""
|
||||
|
|
@ -88,6 +80,8 @@ class AuditlogModelRegistry:
|
|||
:param exclude_fields: The fields to exclude. Overrides the fields to include.
|
||||
:param 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
|
||||
|
|
@ -116,6 +110,9 @@ class AuditlogModelRegistry:
|
|||
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):
|
||||
|
|
@ -126,6 +123,7 @@ class AuditlogModelRegistry:
|
|||
"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,
|
||||
|
|
@ -169,7 +167,7 @@ class AuditlogModelRegistry:
|
|||
else:
|
||||
self._disconnect_signals(model)
|
||||
|
||||
def get_models(self) -> List[ModelBase]:
|
||||
def get_models(self) -> list[ModelBase]:
|
||||
return list(self._registry.keys())
|
||||
|
||||
def get_model_fields(self, model: ModelBase):
|
||||
|
|
@ -178,6 +176,7 @@ class AuditlogModelRegistry:
|
|||
"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):
|
||||
|
|
@ -211,7 +210,7 @@ class AuditlogModelRegistry:
|
|||
m2m_changed.connect(
|
||||
receiver,
|
||||
sender=m2m_model,
|
||||
dispatch_uid=self._dispatch_uid(m2m_changed, receiver),
|
||||
dispatch_uid=self._m2m_dispatch_uid(m2m_changed, m2m_model),
|
||||
)
|
||||
|
||||
def _disconnect_signals(self, model):
|
||||
|
|
@ -227,7 +226,7 @@ class AuditlogModelRegistry:
|
|||
m2m_model = getattr(field, "through")
|
||||
m2m_changed.disconnect(
|
||||
sender=m2m_model,
|
||||
dispatch_uid=self._dispatch_uid(m2m_changed, receiver),
|
||||
dispatch_uid=self._m2m_dispatch_uid(m2m_changed, m2m_model),
|
||||
)
|
||||
del self._m2m_signals[model]
|
||||
|
||||
|
|
@ -235,7 +234,11 @@ class AuditlogModelRegistry:
|
|||
"""Generate a dispatch_uid which is unique for a combination of self, signal, and receiver."""
|
||||
return id(self), id(signal), id(receiver)
|
||||
|
||||
def _get_model_classes(self, app_model: str) -> List[ModelBase]:
|
||||
def _m2m_dispatch_uid(self, signal, sender) -> DispatchUID:
|
||||
"""Generate a dispatch_uid which is unique for a combination of self, signal, and sender."""
|
||||
return id(self), id(signal), id(sender)
|
||||
|
||||
def _get_model_classes(self, app_model: str) -> list[ModelBase]:
|
||||
try:
|
||||
try:
|
||||
app_label, model_name = app_model.split(".")
|
||||
|
|
@ -247,7 +250,7 @@ class AuditlogModelRegistry:
|
|||
|
||||
def _get_exclude_models(
|
||||
self, exclude_tracking_models: Iterable[str]
|
||||
) -> List[ModelBase]:
|
||||
) -> list[ModelBase]:
|
||||
exclude_models = [
|
||||
model
|
||||
for app_model in tuple(exclude_tracking_models)
|
||||
|
|
@ -256,7 +259,7 @@ class AuditlogModelRegistry:
|
|||
]
|
||||
return exclude_models
|
||||
|
||||
def _register_models(self, models: Iterable[Union[str, Dict[str, Any]]]) -> None:
|
||||
def _register_models(self, models: Iterable[str | dict[str, Any]]) -> None:
|
||||
models = copy.deepcopy(models)
|
||||
for model in models:
|
||||
if isinstance(model, str):
|
||||
|
|
@ -305,6 +308,15 @@ class AuditlogModelRegistry:
|
|||
"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"
|
||||
|
|
@ -315,6 +327,11 @@ class AuditlogModelRegistry:
|
|||
"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(
|
||||
|
|
@ -359,6 +376,9 @@ class AuditlogModelRegistry:
|
|||
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)
|
||||
|
||||
|
||||
|
|
|
|||
95
auditlog/render.py
Normal file
95
auditlog/render.py
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
from django.core.exceptions import FieldDoesNotExist
|
||||
from django.forms.utils import pretty_name
|
||||
from django.utils.html import format_html, format_html_join
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
def render_logentry_changes_html(log_entry):
|
||||
changes = log_entry.changes_dict
|
||||
if not changes:
|
||||
return ""
|
||||
|
||||
atom_changes = {}
|
||||
m2m_changes = {}
|
||||
|
||||
# Separate regular fields from M2M changes
|
||||
for field, change in changes.items():
|
||||
if isinstance(change, dict) and change.get("type") == "m2m":
|
||||
m2m_changes[field] = change
|
||||
else:
|
||||
atom_changes[field] = change
|
||||
|
||||
html_parts = []
|
||||
|
||||
# Render regular field changes
|
||||
if atom_changes:
|
||||
html_parts.append(_render_field_changes(log_entry, atom_changes))
|
||||
|
||||
# Render M2M relationship changes
|
||||
if m2m_changes:
|
||||
html_parts.append(_render_m2m_changes(log_entry, m2m_changes))
|
||||
|
||||
return mark_safe("".join(html_parts))
|
||||
|
||||
|
||||
def get_field_verbose_name(log_entry, field_name):
|
||||
from auditlog.registry import auditlog
|
||||
|
||||
model = log_entry.content_type.model_class()
|
||||
if model is None:
|
||||
return field_name
|
||||
|
||||
# Try to get verbose name from auditlog mapping
|
||||
try:
|
||||
if auditlog.contains(model._meta.model):
|
||||
model_fields = auditlog.get_model_fields(model._meta.model)
|
||||
mapping_field_name = model_fields["mapping_fields"].get(field_name)
|
||||
if mapping_field_name:
|
||||
return mapping_field_name
|
||||
except KeyError:
|
||||
# Model definition in auditlog was probably removed
|
||||
pass
|
||||
|
||||
# Fall back to Django field verbose_name
|
||||
try:
|
||||
field = model._meta.get_field(field_name)
|
||||
return pretty_name(getattr(field, "verbose_name", field_name))
|
||||
except FieldDoesNotExist:
|
||||
return pretty_name(field_name)
|
||||
|
||||
|
||||
def _render_field_changes(log_entry, atom_changes):
|
||||
rows = []
|
||||
rows.append(_format_header("#", _("Field"), _("From"), _("To")))
|
||||
|
||||
for i, (field, change) in enumerate(sorted(atom_changes.items()), 1):
|
||||
field_name = get_field_verbose_name(log_entry, field)
|
||||
values = ["***", "***"] if field == "password" else change
|
||||
rows.append(_format_row(i, field_name, *values))
|
||||
|
||||
return f"<table>{''.join(rows)}</table>"
|
||||
|
||||
|
||||
def _render_m2m_changes(log_entry, m2m_changes):
|
||||
rows = []
|
||||
rows.append(_format_header("#", _("Relationship"), _("Action"), _("Objects")))
|
||||
|
||||
for i, (field, change) in enumerate(sorted(m2m_changes.items()), 1):
|
||||
field_name = get_field_verbose_name(log_entry, field)
|
||||
objects_html = format_html_join(
|
||||
mark_safe("<br>"),
|
||||
"{}",
|
||||
[(obj,) for obj in change["objects"]],
|
||||
)
|
||||
rows.append(_format_row(i, field_name, change["operation"], objects_html))
|
||||
|
||||
return f"<table>{''.join(rows)}</table>"
|
||||
|
||||
|
||||
def _format_header(*labels):
|
||||
return format_html("".join(["<tr>", "<th>{}</th>" * len(labels), "</tr>"]), *labels)
|
||||
|
||||
|
||||
def _format_row(*values):
|
||||
return format_html("".join(["<tr>", "<td>{}</td>" * len(values), "</tr>"]), *values)
|
||||
18
auditlog/templates/auditlog/entry_detail.html
Normal file
18
auditlog/templates/auditlog/entry_detail.html
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{% load i18n auditlog_tags %}
|
||||
|
||||
<div class="auditlog-entry">
|
||||
<div class="entry-header">
|
||||
<div class="entry-meta">
|
||||
<span class="entry-timestamp">{{ entry.timestamp|date:"DATETIME_FORMAT" }}</span>
|
||||
<span class="entry-user">{% if entry.actor %}{{ entry.actor }}{% else %}{% trans 'system' %}{% endif %}</span>
|
||||
<span class="entry-action">{{ entry.get_action_display }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="entry-content">
|
||||
{% if entry.action == entry.Action.DELETE or entry.action == entry.Action.ACCESS %}
|
||||
<span class="no-changes">{% trans 'No field changes' %}</span>
|
||||
{% else %}
|
||||
{{ entry|render_logentry_changes_html|safe }}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
160
auditlog/templates/auditlog/object_history.html
Normal file
160
auditlog/templates/auditlog/object_history.html
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
{% extends "admin/base_site.html" %}
|
||||
{% load i18n admin_urls static %}
|
||||
|
||||
{% block extrahead %}
|
||||
{{ block.super }}
|
||||
<style type="text/css">
|
||||
.auditlog-entries {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
max-width: 1200px;
|
||||
}
|
||||
|
||||
.auditlog-entry {
|
||||
border: 1px solid var(--hairline-color, #e1e1e1);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.entry-header {
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--hairline-color, #e1e1e1);
|
||||
}
|
||||
|
||||
.entry-meta {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.entry-timestamp {
|
||||
font-weight: 600;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.entry-user {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.entry-action {
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 0.8em;
|
||||
font-weight: 500;
|
||||
border: 1px solid var(--hairline-color, #e1e1e1);
|
||||
}
|
||||
|
||||
.entry-content {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.no-changes {
|
||||
font-style: italic;
|
||||
opacity: 0.7;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
/* Table styling */
|
||||
.entry-content table {
|
||||
width: auto;
|
||||
min-width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 6px 0;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.entry-content table th,
|
||||
.entry-content table td {
|
||||
padding: 6px 8px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
border: 1px solid var(--hairline-color, #e1e1e1);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.entry-content table th {
|
||||
font-weight: 600;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.entry-content table td {
|
||||
max-width: 200px;
|
||||
word-wrap: break-word;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.entry-content table + table {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* Pagination styling */
|
||||
.pagination {
|
||||
margin-top: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pagination a,
|
||||
.pagination span {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
margin: 0 2px;
|
||||
border: 1px solid var(--hairline-color, #e1e1e1);
|
||||
text-decoration: none;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.pagination .current {
|
||||
font-weight: 600;
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
|
||||
.pagination-info {
|
||||
text-align: center;
|
||||
margin-top: 8px;
|
||||
opacity: 0.7;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.auditlog-entries {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.entry-content {
|
||||
padding: 6px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block breadcrumbs %}
|
||||
<div class="breadcrumbs">
|
||||
<a href="{% url 'admin:index' %}">{% translate 'Home' %}</a>
|
||||
› <a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ opts.app_config.verbose_name }}</a>
|
||||
› <a href="{% url opts|admin_urlname:'changelist' %}">{{ module_name }}</a>
|
||||
› <a href="{% url opts|admin_urlname:'change' object.pk|admin_urlquote %}">{{ object|truncatewords:'18' }}</a>
|
||||
› {% translate 'Audit log' %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="content-main">
|
||||
<div id="auditlog-history" class="module">
|
||||
{% if log_entries %}
|
||||
<div class="auditlog-entries">
|
||||
{% for entry in log_entries %}
|
||||
{% include "auditlog/entry_detail.html" with entry=entry %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if pagination_required %}
|
||||
{% include "auditlog/pagination.html" with page_obj=log_entries page_var=page_var %}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p>{% trans 'No log entries found.' %}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
16
auditlog/templates/auditlog/pagination.html
Normal file
16
auditlog/templates/auditlog/pagination.html
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{% load i18n %}
|
||||
|
||||
<div class="pagination">
|
||||
{% for i in page_obj.paginator.page_range %}
|
||||
{% if i == page_obj.paginator.ELLIPSIS %}
|
||||
<span>...</span>
|
||||
{% elif i == page_obj.number %}
|
||||
<span class="current">{{ i }}</span>
|
||||
{% else %}
|
||||
<a href="?{{ page_var }}={{ i }}">{{ i }}</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
<p class="pagination-info">
|
||||
{{ page_obj.paginator.count }} {% blocktranslate count counter=page_obj.paginator.count %}entry{% plural %}entries{% endblocktranslate %}
|
||||
</p>
|
||||
16
auditlog/templatetags/auditlog_tags.py
Normal file
16
auditlog/templatetags/auditlog_tags.py
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
from django import template
|
||||
|
||||
from auditlog.render import render_logentry_changes_html as render_changes
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.filter
|
||||
def render_logentry_changes_html(log_entry):
|
||||
"""
|
||||
Format LogEntry changes as HTML.
|
||||
|
||||
Usage in template:
|
||||
{{ log_entry_object|render_logentry_changes_html|safe }}
|
||||
"""
|
||||
return render_changes(log_entry)
|
||||
0
auditlog_tests/auditlog
Normal file
0
auditlog_tests/auditlog
Normal file
0
auditlog_tests/custom_logentry_app/__init__.py
Normal file
0
auditlog_tests/custom_logentry_app/__init__.py
Normal file
5
auditlog_tests/custom_logentry_app/apps.py
Normal file
5
auditlog_tests/custom_logentry_app/apps.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CustomLogEntryConfig(AppConfig):
|
||||
name = "custom_logentry_app"
|
||||
138
auditlog_tests/custom_logentry_app/migrations/0001_initial.py
Normal file
138
auditlog_tests/custom_logentry_app/migrations/0001_initial.py
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
# Generated by Django 4.2.25 on 2025-10-14 04:17
|
||||
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
("contenttypes", "0002_remove_content_type_name"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="CustomLogEntryModel",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"object_pk",
|
||||
models.CharField(
|
||||
db_index=True, max_length=255, verbose_name="object pk"
|
||||
),
|
||||
),
|
||||
(
|
||||
"object_id",
|
||||
models.BigIntegerField(
|
||||
blank=True, db_index=True, null=True, verbose_name="object id"
|
||||
),
|
||||
),
|
||||
("object_repr", models.TextField(verbose_name="object representation")),
|
||||
("serialized_data", models.JSONField(null=True)),
|
||||
(
|
||||
"action",
|
||||
models.PositiveSmallIntegerField(
|
||||
choices=[
|
||||
(0, "create"),
|
||||
(1, "update"),
|
||||
(2, "delete"),
|
||||
(3, "access"),
|
||||
],
|
||||
db_index=True,
|
||||
verbose_name="action",
|
||||
),
|
||||
),
|
||||
(
|
||||
"changes_text",
|
||||
models.TextField(blank=True, verbose_name="change message"),
|
||||
),
|
||||
("changes", models.JSONField(null=True, verbose_name="change message")),
|
||||
(
|
||||
"cid",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
db_index=True,
|
||||
max_length=255,
|
||||
null=True,
|
||||
verbose_name="Correlation ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"remote_addr",
|
||||
models.GenericIPAddressField(
|
||||
blank=True, null=True, verbose_name="remote address"
|
||||
),
|
||||
),
|
||||
(
|
||||
"remote_port",
|
||||
models.PositiveIntegerField(
|
||||
blank=True, null=True, verbose_name="remote port"
|
||||
),
|
||||
),
|
||||
(
|
||||
"timestamp",
|
||||
models.DateTimeField(
|
||||
db_index=True,
|
||||
default=django.utils.timezone.now,
|
||||
verbose_name="timestamp",
|
||||
),
|
||||
),
|
||||
(
|
||||
"additional_data",
|
||||
models.JSONField(
|
||||
blank=True, null=True, verbose_name="additional data"
|
||||
),
|
||||
),
|
||||
(
|
||||
"actor_email",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
max_length=254,
|
||||
null=True,
|
||||
verbose_name="actor email",
|
||||
),
|
||||
),
|
||||
("role", models.CharField(blank=True, max_length=100, null=True)),
|
||||
(
|
||||
"actor",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="+",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="actor",
|
||||
),
|
||||
),
|
||||
(
|
||||
"content_type",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="+",
|
||||
to="contenttypes.contenttype",
|
||||
verbose_name="content type",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "log entry",
|
||||
"verbose_name_plural": "log entries",
|
||||
"ordering": ["-timestamp"],
|
||||
"get_latest_by": "timestamp",
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
]
|
||||
7
auditlog_tests/custom_logentry_app/models.py
Normal file
7
auditlog_tests/custom_logentry_app/models.py
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
from django.db import models
|
||||
|
||||
from auditlog.models import AbstractLogEntry
|
||||
|
||||
|
||||
class CustomLogEntryModel(AbstractLogEntry):
|
||||
role = models.CharField(max_length=100, null=True, blank=True)
|
||||
0
auditlog_tests/custom_logentry_app/urls.py
Normal file
0
auditlog_tests/custom_logentry_app/urls.py
Normal file
0
auditlog_tests/custom_logentry_app/views.py
Normal file
0
auditlog_tests/custom_logentry_app/views.py
Normal file
47
auditlog_tests/docker-compose.yml
Normal file
47
auditlog_tests/docker-compose.yml
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
services:
|
||||
postgres:
|
||||
container_name: auditlog_postgres
|
||||
image: postgres:15
|
||||
restart: "no"
|
||||
environment:
|
||||
POSTGRES_DB: auditlog
|
||||
POSTGRES_USER: ${TEST_DB_USER}
|
||||
POSTGRES_PASSWORD: ${TEST_DB_PASS}
|
||||
ports:
|
||||
- "${TEST_DB_PORT:-5432}:5432"
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: pg_isready -U ${TEST_DB_USER} -d auditlog
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
|
||||
mysql:
|
||||
container_name: auditlog_mysql
|
||||
platform: linux/x86_64
|
||||
image: mysql:8.4
|
||||
restart: "no"
|
||||
environment:
|
||||
MYSQL_DATABASE: auditlog
|
||||
MYSQL_USER: ${TEST_DB_USER}
|
||||
MYSQL_PASSWORD: ${TEST_DB_PASS}
|
||||
MYSQL_ROOT_PASSWORD: ${TEST_DB_PASS}
|
||||
ports:
|
||||
- "${TEST_DB_PORT:-3306}:3306"
|
||||
expose:
|
||||
- '${TEST_DB_PORT:-3306}'
|
||||
volumes:
|
||||
- mysql-data:/var/lib/mysql
|
||||
- ./docker/db/init-mysql.sh:/docker-entrypoint-initdb.d/init.sh
|
||||
healthcheck:
|
||||
test: mysqladmin ping -h 127.0.0.1 -u ${TEST_DB_USER} --password=${TEST_DB_PASS}
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 3
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
driver: local
|
||||
mysql-data:
|
||||
driver: local
|
||||
6
auditlog_tests/docker/db/init-mysql.sh
Executable file
6
auditlog_tests/docker/db/init-mysql.sh
Executable file
|
|
@ -0,0 +1,6 @@
|
|||
#!/usr/bin/env bash
|
||||
set -e
|
||||
|
||||
mysql -u root -p"$MYSQL_ROOT_PASSWORD" <<-EOSQL
|
||||
GRANT ALL PRIVILEGES ON test_auditlog.* to '$MYSQL_USER';
|
||||
EOSQL
|
||||
|
|
@ -3,7 +3,7 @@ import os
|
|||
import sys
|
||||
|
||||
if __name__ == "__main__":
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "auditlog_tests.test_settings")
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_settings")
|
||||
|
||||
from django.core.management import execute_from_command_line
|
||||
|
||||
|
|
|
|||
12
auditlog_tests/middleware.py
Normal file
12
auditlog_tests/middleware.py
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
from auditlog.middleware import AuditlogMiddleware
|
||||
|
||||
|
||||
class CustomAuditlogMiddleware(AuditlogMiddleware):
|
||||
"""
|
||||
Custom Middleware to couple the request's user role to log items.
|
||||
"""
|
||||
|
||||
def get_extra_data(self, request):
|
||||
context_data = super().get_extra_data(request)
|
||||
context_data["role"] = "Role 1"
|
||||
return context_data
|
||||
0
auditlog_tests/test_app/__init__.py
Normal file
0
auditlog_tests/test_app/__init__.py
Normal file
|
|
@ -2,4 +2,4 @@ from django.apps import AppConfig
|
|||
|
||||
|
||||
class AuditlogTestConfig(AppConfig):
|
||||
name = "auditlog_tests"
|
||||
name = "test_app"
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
[
|
||||
{
|
||||
"model": "auditlog_tests.manyrelatedmodel",
|
||||
"model": "test_app.manyrelatedmodel",
|
||||
"pk": 1,
|
||||
"fields": {
|
||||
"recursive": [1],
|
||||
|
|
@ -8,7 +8,7 @@
|
|||
}
|
||||
},
|
||||
{
|
||||
"model": "auditlog_tests.manyrelatedothermodel",
|
||||
"model": "test_app.manyrelatedothermodel",
|
||||
"pk": 1,
|
||||
"fields": {}
|
||||
}
|
||||
6
auditlog_tests/test_app/mask.py
Normal file
6
auditlog_tests/test_app/mask.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
def custom_mask_str(value: str) -> str:
|
||||
"""Custom masking function that only shows the last 4 characters."""
|
||||
if len(value) > 4:
|
||||
return "****" + value[-4:]
|
||||
|
||||
return value
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import uuid
|
||||
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.conf import settings
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.db import models
|
||||
|
||||
|
|
@ -20,6 +20,7 @@ class SimpleModel(models.Model):
|
|||
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)
|
||||
|
||||
|
|
@ -57,6 +58,26 @@ class UUIDPrimaryKeyModel(models.Model):
|
|||
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.
|
||||
|
|
@ -113,6 +134,61 @@ class ManyRelatedOtherModel(models.Model):
|
|||
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):
|
||||
"""
|
||||
|
|
@ -235,26 +311,36 @@ class CharfieldTextfieldModel(models.Model):
|
|||
history = AuditlogHistoryField(delete_related=True)
|
||||
|
||||
|
||||
class PostgresArrayFieldModel(models.Model):
|
||||
"""
|
||||
Test auditlog with Postgres's ArrayField
|
||||
"""
|
||||
# Only define PostgreSQL-specific models when ArrayField is available
|
||||
if settings.TEST_DB_BACKEND == "postgresql":
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
|
||||
RED = "r"
|
||||
YELLOW = "y"
|
||||
GREEN = "g"
|
||||
class PostgresArrayFieldModel(models.Model):
|
||||
"""
|
||||
Test auditlog with Postgres's ArrayField
|
||||
"""
|
||||
|
||||
STATUS_CHOICES = (
|
||||
(RED, "Red"),
|
||||
(YELLOW, "Yellow"),
|
||||
(GREEN, "Green"),
|
||||
)
|
||||
RED = "r"
|
||||
YELLOW = "y"
|
||||
GREEN = "g"
|
||||
|
||||
arrayfield = ArrayField(
|
||||
models.CharField(max_length=1, choices=STATUS_CHOICES), size=3
|
||||
)
|
||||
STATUS_CHOICES = (
|
||||
(RED, "Red"),
|
||||
(YELLOW, "Yellow"),
|
||||
(GREEN, "Green"),
|
||||
)
|
||||
|
||||
history = AuditlogHistoryField(delete_related=True)
|
||||
arrayfield = ArrayField(
|
||||
models.CharField(max_length=1, choices=STATUS_CHOICES), size=3
|
||||
)
|
||||
|
||||
history = AuditlogHistoryField(delete_related=True)
|
||||
|
||||
else:
|
||||
|
||||
class PostgresArrayFieldModel(models.Model):
|
||||
class Meta:
|
||||
managed = False
|
||||
|
||||
|
||||
class NoDeleteHistoryModel(models.Model):
|
||||
|
|
@ -269,6 +355,12 @@ class JSONModel(models.Model):
|
|||
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()
|
||||
|
|
@ -326,26 +418,95 @@ class SimpleNonManagedModel(models.Model):
|
|||
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)
|
||||
auditlog.register(PostgresArrayFieldModel)
|
||||
if settings.TEST_DB_BACKEND == "postgresql":
|
||||
auditlog.register(PostgresArrayFieldModel)
|
||||
auditlog.register(NoDeleteHistoryModel)
|
||||
auditlog.register(JSONModel)
|
||||
auditlog.register(NullableJSONModel)
|
||||
auditlog.register(
|
||||
SerializeThisModel,
|
||||
serialize_data=True,
|
||||
|
|
@ -363,3 +524,9 @@ auditlog.register(
|
|||
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)
|
||||
|
|
@ -1,13 +1,13 @@
|
|||
from django.contrib import admin
|
||||
from django.urls import path
|
||||
|
||||
from auditlog_tests.views import SimpleModelDetailview
|
||||
from .views import SimpleModelDetailView
|
||||
|
||||
urlpatterns = [
|
||||
path("admin/", admin.site.urls),
|
||||
path(
|
||||
"simplemodel/<int:pk>/",
|
||||
SimpleModelDetailview.as_view(),
|
||||
SimpleModelDetailView.as_view(),
|
||||
name="simplemodel-detail",
|
||||
),
|
||||
]
|
||||
|
|
@ -1,9 +1,10 @@
|
|||
from django.views.generic import DetailView
|
||||
|
||||
from auditlog.mixins import LogAccessMixin
|
||||
from auditlog_tests.models import SimpleModel
|
||||
|
||||
from .models import SimpleModel
|
||||
|
||||
|
||||
class SimpleModelDetailview(LogAccessMixin, DetailView):
|
||||
class SimpleModelDetailView(LogAccessMixin, DetailView):
|
||||
model = SimpleModel
|
||||
template_name = "simplemodel_detail.html"
|
||||
|
|
@ -6,9 +6,12 @@ from unittest import mock
|
|||
|
||||
import freezegun
|
||||
from django.core.management import call_command
|
||||
from django.test import TestCase
|
||||
from django.db import connection
|
||||
from django.test import TestCase, TransactionTestCase
|
||||
from django.test.utils import skipIf
|
||||
from test_app.models import SimpleModel
|
||||
|
||||
from auditlog_tests.models import SimpleModel
|
||||
from auditlog.management.commands.auditlogflush import TruncateQuery
|
||||
|
||||
|
||||
class AuditlogFlushTest(TestCase):
|
||||
|
|
@ -110,3 +113,93 @@ class AuditlogFlushTest(TestCase):
|
|||
out, "Deleted 1 objects.", msg="Output shows deleted 1 object."
|
||||
)
|
||||
self.assertEqual(err, "", msg="No stderr")
|
||||
|
||||
|
||||
class AuditlogFlushWithTruncateTest(TransactionTestCase):
|
||||
def setUp(self):
|
||||
input_patcher = mock.patch("builtins.input")
|
||||
self.mock_input = input_patcher.start()
|
||||
self.addCleanup(input_patcher.stop)
|
||||
|
||||
def _fixture_teardown(self):
|
||||
call_command("flush", verbosity=0, interactive=False, allow_cascade=True)
|
||||
|
||||
def make_object(self):
|
||||
return SimpleModel.objects.create(text="I am a simple model.")
|
||||
|
||||
def call_command(self, *args, **kwargs):
|
||||
outbuf = StringIO()
|
||||
errbuf = StringIO()
|
||||
call_command("auditlogflush", *args, stdout=outbuf, stderr=errbuf, **kwargs)
|
||||
return outbuf.getvalue().strip(), errbuf.getvalue().strip()
|
||||
|
||||
def test_flush_with_both_truncate_and_before_date_options(self):
|
||||
obj = self.make_object()
|
||||
self.assertEqual(obj.history.count(), 1, msg="There is one log entry.")
|
||||
out, err = self.call_command("--truncate", "--before-date=2000-01-01")
|
||||
|
||||
self.assertEqual(obj.history.count(), 1, msg="There is still one log entry.")
|
||||
self.assertEqual(
|
||||
out,
|
||||
"Truncate deletes all log entries and can not be passed with before-date.",
|
||||
msg="Output shows error",
|
||||
)
|
||||
self.assertEqual(err, "", msg="No stderr")
|
||||
|
||||
@skipIf(
|
||||
not TruncateQuery.support_truncate_statement(connection.vendor),
|
||||
"Database does not support TRUNCATE",
|
||||
)
|
||||
def test_flush_with_truncate_and_yes(self):
|
||||
obj = self.make_object()
|
||||
self.assertEqual(obj.history.count(), 1, msg="There is one log entry.")
|
||||
out, err = self.call_command("--truncate", "--y")
|
||||
|
||||
self.assertEqual(obj.history.count(), 0, msg="There is no log entry.")
|
||||
self.assertEqual(
|
||||
out,
|
||||
"Truncated log entry table.",
|
||||
msg="Output shows table gets truncate",
|
||||
)
|
||||
self.assertEqual(err, "", msg="No stderr")
|
||||
|
||||
@skipIf(
|
||||
not TruncateQuery.support_truncate_statement(connection.vendor),
|
||||
"Database does not support TRUNCATE",
|
||||
)
|
||||
def test_flush_with_truncate_with_input_yes(self):
|
||||
obj = self.make_object()
|
||||
self.assertEqual(obj.history.count(), 1, msg="There is one log entry.")
|
||||
self.mock_input.return_value = "Y\n"
|
||||
out, err = self.call_command("--truncate")
|
||||
|
||||
self.assertEqual(obj.history.count(), 0, msg="There is no log entry.")
|
||||
self.assertEqual(
|
||||
out,
|
||||
"This action will clear all log entries from the database.\nTruncated log entry table.",
|
||||
msg="Output shows warning and table gets truncate",
|
||||
)
|
||||
self.assertEqual(err, "", msg="No stderr")
|
||||
|
||||
@mock.patch(
|
||||
"django.db.connection.vendor",
|
||||
new_callable=mock.PropertyMock(return_value="unknown"),
|
||||
)
|
||||
@mock.patch(
|
||||
"django.db.connection.display_name",
|
||||
new_callable=mock.PropertyMock(return_value="Unknown"),
|
||||
)
|
||||
def test_flush_with_truncate_for_unsupported_database_vendor(
|
||||
self, mocked_vendor, mocked_db_name
|
||||
):
|
||||
obj = self.make_object()
|
||||
self.assertEqual(obj.history.count(), 1, msg="There is one log entry.")
|
||||
out, err = self.call_command("--truncate", "--y")
|
||||
|
||||
self.assertEqual(obj.history.count(), 1, msg="There is still one log entry.")
|
||||
self.assertEqual(
|
||||
out,
|
||||
"Database Unknown does not support truncate statement.",
|
||||
msg="Output shows error",
|
||||
)
|
||||
self.assertEqual(err, "", msg="No stderr")
|
||||
|
|
|
|||
51
auditlog_tests/test_postgresql.py
Normal file
51
auditlog_tests/test_postgresql.py
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
"""
|
||||
PostgreSQL-specific tests for django-auditlog.
|
||||
"""
|
||||
|
||||
from unittest import skipIf
|
||||
|
||||
from django.conf import settings
|
||||
from django.test import TestCase
|
||||
from test_app.models import PostgresArrayFieldModel
|
||||
|
||||
|
||||
@skipIf(settings.TEST_DB_BACKEND != "postgresql", "PostgreSQL-specific test")
|
||||
class PostgresArrayFieldModelTest(TestCase):
|
||||
databases = "__all__"
|
||||
|
||||
def setUp(self):
|
||||
self.obj = PostgresArrayFieldModel.objects.create(
|
||||
arrayfield=[PostgresArrayFieldModel.RED, PostgresArrayFieldModel.GREEN],
|
||||
)
|
||||
|
||||
@property
|
||||
def latest_array_change(self):
|
||||
return self.obj.history.latest().changes_display_dict["arrayfield"][1]
|
||||
|
||||
def test_changes_display_dict_arrayfield(self):
|
||||
self.assertEqual(
|
||||
self.latest_array_change,
|
||||
"Red, Green",
|
||||
msg="The human readable text for the two choices, 'Red, Green' is displayed.",
|
||||
)
|
||||
self.obj.arrayfield = [PostgresArrayFieldModel.GREEN]
|
||||
self.obj.save()
|
||||
self.assertEqual(
|
||||
self.latest_array_change,
|
||||
"Green",
|
||||
msg="The human readable text 'Green' is displayed.",
|
||||
)
|
||||
self.obj.arrayfield = []
|
||||
self.obj.save()
|
||||
self.assertEqual(
|
||||
self.latest_array_change,
|
||||
"",
|
||||
msg="The human readable text '' is displayed.",
|
||||
)
|
||||
self.obj.arrayfield = [PostgresArrayFieldModel.GREEN]
|
||||
self.obj.save()
|
||||
self.assertEqual(
|
||||
self.latest_array_change,
|
||||
"Green",
|
||||
msg="The human readable text 'Green' is displayed.",
|
||||
)
|
||||
167
auditlog_tests/test_render.py
Normal file
167
auditlog_tests/test_render.py
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
from django.test import TestCase
|
||||
from test_app.models import SimpleModel
|
||||
|
||||
from auditlog import get_logentry_model
|
||||
from auditlog.templatetags.auditlog_tags import render_logentry_changes_html
|
||||
|
||||
LogEntry = get_logentry_model()
|
||||
|
||||
|
||||
class RenderChangesTest(TestCase):
|
||||
|
||||
def _create_log_entry(self, action, changes):
|
||||
return LogEntry.objects.log_create(
|
||||
SimpleModel.objects.create(),
|
||||
action=action,
|
||||
changes=changes,
|
||||
)
|
||||
|
||||
def test_render_changes_empty(self):
|
||||
log_entry = self._create_log_entry(LogEntry.Action.CREATE, {})
|
||||
result = render_logentry_changes_html(log_entry)
|
||||
self.assertEqual(result, "")
|
||||
|
||||
def test_render_changes_simple_field(self):
|
||||
changes = {"text": ["old text", "new text"]}
|
||||
log_entry = self._create_log_entry(LogEntry.Action.UPDATE, changes)
|
||||
|
||||
result = render_logentry_changes_html(log_entry)
|
||||
|
||||
self.assertIn("<table>", result)
|
||||
self.assertIn("<th>#</th>", result)
|
||||
self.assertIn("<th>Field</th>", result)
|
||||
self.assertIn("<th>From</th>", result)
|
||||
self.assertIn("<th>To</th>", result)
|
||||
self.assertIn("old text", result)
|
||||
self.assertIn("new text", result)
|
||||
self.assertIsInstance(result, str)
|
||||
|
||||
def test_render_changes_password_field(self):
|
||||
changes = {"password": ["oldpass", "newpass"]}
|
||||
log_entry = self._create_log_entry(LogEntry.Action.UPDATE, changes)
|
||||
|
||||
result = render_logentry_changes_html(log_entry)
|
||||
|
||||
self.assertIn("***", result)
|
||||
self.assertNotIn("oldpass", result)
|
||||
self.assertNotIn("newpass", result)
|
||||
|
||||
def test_render_changes_m2m_field(self):
|
||||
changes = {
|
||||
"related_objects": {
|
||||
"type": "m2m",
|
||||
"operation": "add",
|
||||
"objects": ["obj1", "obj2", "obj3"],
|
||||
}
|
||||
}
|
||||
log_entry = self._create_log_entry(LogEntry.Action.UPDATE, changes)
|
||||
|
||||
result = render_logentry_changes_html(log_entry)
|
||||
|
||||
self.assertIn("<table>", result)
|
||||
self.assertIn("<th>#</th>", result)
|
||||
self.assertIn("<th>Relationship</th>", result)
|
||||
self.assertIn("<th>Action</th>", result)
|
||||
self.assertIn("<th>Objects</th>", result)
|
||||
self.assertIn("add", result)
|
||||
self.assertIn("obj1", result)
|
||||
self.assertIn("obj2", result)
|
||||
self.assertIn("obj3", result)
|
||||
|
||||
def test_render_changes_mixed_fields(self):
|
||||
changes = {
|
||||
"text": ["old text", "new text"],
|
||||
"related_objects": {
|
||||
"type": "m2m",
|
||||
"operation": "remove",
|
||||
"objects": ["obj1"],
|
||||
},
|
||||
}
|
||||
log_entry = self._create_log_entry(LogEntry.Action.UPDATE, changes)
|
||||
|
||||
result = render_logentry_changes_html(log_entry)
|
||||
|
||||
tables = result.count("<table>")
|
||||
self.assertEqual(tables, 2)
|
||||
|
||||
self.assertIn("old text", result)
|
||||
self.assertIn("new text", result)
|
||||
|
||||
self.assertIn("remove", result)
|
||||
self.assertIn("obj1", result)
|
||||
|
||||
def test_render_changes_field_verbose_name(self):
|
||||
changes = {"text": ["old", "new"]}
|
||||
log_entry = self._create_log_entry(LogEntry.Action.UPDATE, changes)
|
||||
|
||||
result = render_logentry_changes_html(log_entry)
|
||||
|
||||
self.assertIn("Text", result)
|
||||
|
||||
def test_render_changes_with_none_values(self):
|
||||
changes = {"text": [None, "new text"], "boolean": [True, None]}
|
||||
log_entry = self._create_log_entry(LogEntry.Action.UPDATE, changes)
|
||||
|
||||
result = render_logentry_changes_html(log_entry)
|
||||
|
||||
self.assertIn("None", result)
|
||||
self.assertIn("new text", result)
|
||||
self.assertIn("True", result)
|
||||
|
||||
def test_render_changes_sorted_fields(self):
|
||||
changes = {
|
||||
"z_field": ["old", "new"],
|
||||
"a_field": ["old", "new"],
|
||||
"m_field": ["old", "new"],
|
||||
}
|
||||
log_entry = self._create_log_entry(LogEntry.Action.UPDATE, changes)
|
||||
|
||||
result = render_logentry_changes_html(log_entry)
|
||||
|
||||
a_index = result.find("A field")
|
||||
m_index = result.find("M field")
|
||||
z_index = result.find("Z field")
|
||||
|
||||
self.assertLess(a_index, m_index)
|
||||
self.assertLess(m_index, z_index)
|
||||
|
||||
def test_render_changes_m2m_sorted_fields(self):
|
||||
changes = {
|
||||
"z_related": {"type": "m2m", "operation": "add", "objects": ["obj1"]},
|
||||
"a_related": {"type": "m2m", "operation": "remove", "objects": ["obj2"]},
|
||||
}
|
||||
log_entry = self._create_log_entry(LogEntry.Action.UPDATE, changes)
|
||||
|
||||
result = render_logentry_changes_html(log_entry)
|
||||
|
||||
a_index = result.find("A related")
|
||||
z_index = result.find("Z related")
|
||||
|
||||
self.assertLess(a_index, z_index)
|
||||
|
||||
def test_render_changes_create_action(self):
|
||||
changes = {
|
||||
"text": [None, "new value"],
|
||||
"boolean": [None, True],
|
||||
}
|
||||
log_entry = self._create_log_entry(LogEntry.Action.CREATE, changes)
|
||||
|
||||
result = render_logentry_changes_html(log_entry)
|
||||
|
||||
self.assertIn("<table>", result)
|
||||
self.assertIn("new value", result)
|
||||
self.assertIn("True", result)
|
||||
|
||||
def test_render_changes_delete_action(self):
|
||||
changes = {
|
||||
"text": ["old value", None],
|
||||
"boolean": [True, None],
|
||||
}
|
||||
log_entry = self._create_log_entry(LogEntry.Action.DELETE, changes)
|
||||
|
||||
result = render_logentry_changes_html(log_entry)
|
||||
|
||||
self.assertIn("<table>", result)
|
||||
self.assertIn("old value", result)
|
||||
self.assertIn("True", result)
|
||||
self.assertIn("None", result)
|
||||
|
|
@ -8,6 +8,8 @@ DEBUG = True
|
|||
|
||||
SECRET_KEY = "test"
|
||||
|
||||
TEST_DB_BACKEND = os.getenv("TEST_DB_BACKEND", "sqlite3")
|
||||
|
||||
INSTALLED_APPS = [
|
||||
"django.contrib.auth",
|
||||
"django.contrib.contenttypes",
|
||||
|
|
@ -15,30 +17,72 @@ INSTALLED_APPS = [
|
|||
"django.contrib.sessions",
|
||||
"django.contrib.admin",
|
||||
"django.contrib.staticfiles",
|
||||
"django.contrib.postgres",
|
||||
"custom_logentry_app",
|
||||
"auditlog",
|
||||
"auditlog_tests",
|
||||
"test_app",
|
||||
]
|
||||
|
||||
|
||||
MIDDLEWARE = [
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"auditlog.middleware.AuditlogMiddleware",
|
||||
]
|
||||
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.postgresql",
|
||||
"NAME": os.getenv(
|
||||
"TEST_DB_NAME", "auditlog" + os.environ.get("TOX_PARALLEL_ENV", "")
|
||||
),
|
||||
"USER": os.getenv("TEST_DB_USER", "postgres"),
|
||||
"PASSWORD": os.getenv("TEST_DB_PASS", ""),
|
||||
"HOST": os.getenv("TEST_DB_HOST", "127.0.0.1"),
|
||||
"PORT": os.getenv("TEST_DB_PORT", "5432"),
|
||||
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 = [
|
||||
{
|
||||
|
|
@ -57,8 +101,10 @@ TEMPLATES = [
|
|||
|
||||
STATIC_URL = "/static/"
|
||||
|
||||
ROOT_URLCONF = "auditlog_tests.urls"
|
||||
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")
|
||||
|
|
|
|||
|
|
@ -2,11 +2,15 @@ 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.models import LogEntry
|
||||
from auditlog_tests.models import SimpleModel
|
||||
from auditlog import get_logentry_model
|
||||
|
||||
LogEntry = get_logentry_model()
|
||||
|
||||
|
||||
class TwoStepMigrationTest(TestCase):
|
||||
|
|
@ -44,6 +48,7 @@ class AuditlogMigrateJsonTest(TestCase):
|
|||
def call_command(self, *args, **kwargs):
|
||||
outbuf = StringIO()
|
||||
errbuf = StringIO()
|
||||
args = ("--no-color",) + args
|
||||
call_command(
|
||||
"auditlogmigratejson", *args, stdout=outbuf, stderr=errbuf, **kwargs
|
||||
)
|
||||
|
|
@ -116,13 +121,17 @@ class AuditlogMigrateJsonTest(TestCase):
|
|||
self.make_logentry()
|
||||
|
||||
# Act
|
||||
with patch("auditlog.models.LogEntry.objects.bulk_update") as bulk_update:
|
||||
LogEntry = get_logentry_model()
|
||||
path = f"{LogEntry.__module__}.{LogEntry.__name__}.objects.bulk_update"
|
||||
|
||||
with patch(path) as bulk_update:
|
||||
outbuf, errbuf = self.call_command("-b=1")
|
||||
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()
|
||||
|
|
@ -135,6 +144,22 @@ class AuditlogMigrateJsonTest(TestCase):
|
|||
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()
|
||||
|
|
|
|||
198
auditlog_tests/test_use_json_for_changes.py
Normal file
198
auditlog_tests/test_use_json_for_changes.py
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
from django.test import TestCase, override_settings
|
||||
from test_app.models import JSONModel, NullableFieldModel, RelatedModel, SimpleModel
|
||||
|
||||
from auditlog import get_logentry_model
|
||||
from auditlog.registry import AuditlogModelRegistry
|
||||
|
||||
LogEntry = get_logentry_model()
|
||||
|
||||
|
||||
class JSONForChangesTest(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.test_auditlog = AuditlogModelRegistry()
|
||||
|
||||
@override_settings(AUDITLOG_STORE_JSON_CHANGES="str")
|
||||
def test_wrong_setting_type(self):
|
||||
with self.assertRaisesMessage(
|
||||
TypeError, "Setting 'AUDITLOG_STORE_JSON_CHANGES' must be a boolean"
|
||||
):
|
||||
self.test_auditlog.register_from_settings()
|
||||
|
||||
@override_settings(AUDITLOG_STORE_JSON_CHANGES=True)
|
||||
def test_use_json_for_changes_with_simplemodel(self):
|
||||
self.test_auditlog.register_from_settings()
|
||||
|
||||
smm = SimpleModel()
|
||||
smm.save()
|
||||
changes_dict = smm.history.latest().changes_dict
|
||||
|
||||
# compare the id, text, boolean and datetime fields
|
||||
id_field_changes = changes_dict["id"]
|
||||
self.assertIsNone(id_field_changes[0])
|
||||
self.assertIsInstance(
|
||||
id_field_changes[1], int
|
||||
) # the id depends on state of the database
|
||||
|
||||
text_field_changes = changes_dict["text"]
|
||||
self.assertEqual(text_field_changes, [None, ""])
|
||||
|
||||
boolean_field_changes = changes_dict["boolean"]
|
||||
self.assertEqual(boolean_field_changes, [None, False])
|
||||
|
||||
# datetime should be serialized to string
|
||||
datetime_field_changes = changes_dict["datetime"]
|
||||
self.assertIsNone(datetime_field_changes[0])
|
||||
self.assertIsInstance(datetime_field_changes[1], str)
|
||||
|
||||
@override_settings(AUDITLOG_STORE_JSON_CHANGES=True)
|
||||
def test_use_json_for_changes_with_jsonmodel(self):
|
||||
self.test_auditlog.register_from_settings()
|
||||
|
||||
json_model = JSONModel()
|
||||
json_model.json = {"test_key": "test_value"}
|
||||
json_model.save()
|
||||
changes_dict = json_model.history.latest().changes_dict
|
||||
|
||||
id_field_changes = changes_dict["json"]
|
||||
self.assertEqual(id_field_changes, [None, {"test_key": "test_value"}])
|
||||
|
||||
@override_settings(AUDITLOG_STORE_JSON_CHANGES=True)
|
||||
def test_use_json_for_changes_with_jsonmodel_with_empty_list(self):
|
||||
self.test_auditlog.register_from_settings()
|
||||
|
||||
json_model = JSONModel()
|
||||
json_model.json = []
|
||||
json_model.save()
|
||||
changes_dict = json_model.history.latest().changes_dict
|
||||
|
||||
id_field_changes = changes_dict["json"]
|
||||
self.assertEqual(id_field_changes, [None, []])
|
||||
|
||||
@override_settings(AUDITLOG_STORE_JSON_CHANGES=True)
|
||||
def test_use_json_for_changes_with_jsonmodel_with_complex_data(self):
|
||||
self.test_auditlog.register_from_settings()
|
||||
|
||||
json_model = JSONModel()
|
||||
json_model.json = {
|
||||
"key": "test_value",
|
||||
"key_dict": {"inner_key": "inner_value"},
|
||||
"key_tuple": ("item1", "item2", "item3"),
|
||||
}
|
||||
json_model.save()
|
||||
changes_dict = json_model.history.latest().changes_dict
|
||||
|
||||
id_field_changes = changes_dict["json"]
|
||||
self.assertEqual(
|
||||
id_field_changes,
|
||||
[
|
||||
None,
|
||||
{
|
||||
"key": "test_value",
|
||||
"key_dict": {"inner_key": "inner_value"},
|
||||
"key_tuple": [
|
||||
"item1",
|
||||
"item2",
|
||||
"item3",
|
||||
], # tuple is converted to list, that's ok
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
@override_settings(AUDITLOG_STORE_JSON_CHANGES=True)
|
||||
def test_use_json_for_changes_with_jsonmodel_with_related_model(self):
|
||||
self.test_auditlog.register_from_settings()
|
||||
|
||||
simple = SimpleModel.objects.create()
|
||||
one_simple = SimpleModel.objects.create()
|
||||
related_model = RelatedModel.objects.create(
|
||||
one_to_one=simple, related=one_simple
|
||||
)
|
||||
related_model.save()
|
||||
changes_dict = related_model.history.latest().changes_dict
|
||||
|
||||
field_related_changes = changes_dict["related"]
|
||||
self.assertEqual(field_related_changes, [None, one_simple.id])
|
||||
|
||||
field_one_to_one_changes = changes_dict["one_to_one"]
|
||||
self.assertEqual(field_one_to_one_changes, [None, simple.id])
|
||||
|
||||
@override_settings(AUDITLOG_STORE_JSON_CHANGES=True)
|
||||
def test_use_json_for_changes_update(self):
|
||||
self.test_auditlog.register_from_settings()
|
||||
|
||||
simple = SimpleModel(text="original")
|
||||
simple.save()
|
||||
simple.text = "new"
|
||||
simple.save()
|
||||
|
||||
changes_dict = simple.history.latest().changes_dict
|
||||
|
||||
text_changes = changes_dict["text"]
|
||||
self.assertEqual(text_changes, ["original", "new"])
|
||||
|
||||
@override_settings(AUDITLOG_STORE_JSON_CHANGES=True)
|
||||
def test_use_json_for_changes_delete(self):
|
||||
self.test_auditlog.register_from_settings()
|
||||
|
||||
simple = SimpleModel()
|
||||
simple.save()
|
||||
simple.delete()
|
||||
|
||||
history = LogEntry.objects.all()
|
||||
|
||||
self.assertEqual(history.count(), 1, '"DELETE" record is always retained')
|
||||
|
||||
changes_dict = history.first().changes_dict
|
||||
|
||||
self.assertTrue(
|
||||
all(v[1] is None for k, v in changes_dict.items()),
|
||||
'all values in the changes dict should None, not "None"',
|
||||
)
|
||||
|
||||
@override_settings(AUDITLOG_STORE_JSON_CHANGES=False)
|
||||
def test_nullable_field_with_none_not_logged(self):
|
||||
self.test_auditlog.register_from_settings()
|
||||
|
||||
obj = NullableFieldModel.objects.create(time=None, optional_text=None)
|
||||
changes_dict = obj.history.latest().changes_dict
|
||||
|
||||
# None → None should NOT be logged as a change
|
||||
self.assertNotIn("time", changes_dict)
|
||||
self.assertNotIn("optional_text", changes_dict)
|
||||
|
||||
@override_settings(AUDITLOG_STORE_JSON_CHANGES=False)
|
||||
def test_nullable_field_with_value_logged(self):
|
||||
self.test_auditlog.register_from_settings()
|
||||
|
||||
obj = NullableFieldModel.objects.create(optional_text="something")
|
||||
changes_dict = obj.history.latest().changes_dict
|
||||
|
||||
# None → "something" should be logged
|
||||
self.assertIn("optional_text", changes_dict)
|
||||
self.assertEqual(changes_dict["optional_text"], ["None", "something"])
|
||||
|
||||
@override_settings(AUDITLOG_STORE_JSON_CHANGES=True)
|
||||
def test_nullable_field_with_none_not_logged_json_mode(self):
|
||||
self.test_auditlog.register_from_settings()
|
||||
|
||||
obj = NullableFieldModel.objects.create(time=None, optional_text=None)
|
||||
changes_dict = obj.history.latest().changes_dict
|
||||
|
||||
# None → None should NOT be logged
|
||||
self.assertNotIn("time", changes_dict)
|
||||
self.assertNotIn("optional_text", changes_dict)
|
||||
|
||||
@override_settings(AUDITLOG_STORE_JSON_CHANGES=False)
|
||||
def test_nullable_field_update_none_to_value(self):
|
||||
self.test_auditlog.register_from_settings()
|
||||
|
||||
obj = NullableFieldModel.objects.create(optional_text=None)
|
||||
obj.optional_text = "updated"
|
||||
obj.save()
|
||||
|
||||
changes_dict = obj.history.latest().changes_dict
|
||||
|
||||
# None → "updated" should be logged
|
||||
self.assertIn("optional_text", changes_dict)
|
||||
self.assertEqual(changes_dict["optional_text"], ["None", "updated"])
|
||||
175
auditlog_tests/test_view.py
Normal file
175
auditlog_tests/test_view.py
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
from unittest.mock import patch
|
||||
|
||||
from django.contrib import admin
|
||||
from django.contrib.admin.sites import AdminSite
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import RequestFactory, TestCase
|
||||
from test_app.models import SimpleModel
|
||||
|
||||
from auditlog.mixins import AuditlogHistoryAdminMixin
|
||||
|
||||
|
||||
class TestModelAdmin(AuditlogHistoryAdminMixin, admin.ModelAdmin):
|
||||
model = SimpleModel
|
||||
auditlog_history_per_page = 5
|
||||
|
||||
|
||||
class TestAuditlogHistoryAdminMixin(TestCase):
|
||||
def setUp(self):
|
||||
self.user = get_user_model().objects.create_user(
|
||||
username="test_admin", is_staff=True, is_superuser=True, is_active=True
|
||||
)
|
||||
self.site = AdminSite()
|
||||
|
||||
self.admin = TestModelAdmin(SimpleModel, self.site)
|
||||
|
||||
self.obj = SimpleModel.objects.create(text="Test object")
|
||||
|
||||
def test_auditlog_history_view_requires_permission(self):
|
||||
request = RequestFactory().get("/")
|
||||
request.user = get_user_model().objects.create_user(
|
||||
username="non_staff_user", password="testpass"
|
||||
)
|
||||
|
||||
with self.assertRaises(Exception):
|
||||
self.admin.auditlog_history_view(request, str(self.obj.pk))
|
||||
|
||||
def test_auditlog_history_view_with_permission(self):
|
||||
request = RequestFactory().get("/")
|
||||
request.user = self.user
|
||||
|
||||
response = self.admin.auditlog_history_view(request, str(self.obj.pk))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn("log_entries", response.context_data)
|
||||
self.assertIn("object", response.context_data)
|
||||
self.assertEqual(response.context_data["object"], self.obj)
|
||||
|
||||
def test_auditlog_history_view_pagination(self):
|
||||
"""Test that pagination works correctly."""
|
||||
for i in range(10):
|
||||
self.obj.text = f"Updated text {i}"
|
||||
self.obj.save()
|
||||
|
||||
request = RequestFactory().get("/")
|
||||
request.user = self.user
|
||||
|
||||
response = self.admin.auditlog_history_view(request, str(self.obj.pk))
|
||||
|
||||
self.assertTrue(response.context_data["pagination_required"])
|
||||
self.assertEqual(len(response.context_data["log_entries"]), 5)
|
||||
|
||||
def test_auditlog_history_view_page_parameter(self):
|
||||
# Create more log entries by updating the object
|
||||
for i in range(10):
|
||||
self.obj.text = f"Updated text {i}"
|
||||
self.obj.save()
|
||||
|
||||
request = RequestFactory().get("/?p=2")
|
||||
request.user = self.user
|
||||
|
||||
response = self.admin.auditlog_history_view(request, str(self.obj.pk))
|
||||
|
||||
# Should be on page 2
|
||||
self.assertEqual(response.context_data["log_entries"].number, 2)
|
||||
|
||||
def test_auditlog_history_view_context_data(self):
|
||||
request = RequestFactory().get("/")
|
||||
request.user = self.user
|
||||
|
||||
response = self.admin.auditlog_history_view(request, str(self.obj.pk))
|
||||
|
||||
context = response.context_data
|
||||
required_keys = [
|
||||
"title",
|
||||
"module_name",
|
||||
"page_range",
|
||||
"page_var",
|
||||
"pagination_required",
|
||||
"object",
|
||||
"opts",
|
||||
"log_entries",
|
||||
]
|
||||
|
||||
for key in required_keys:
|
||||
self.assertIn(key, context)
|
||||
|
||||
self.assertIn(str(self.obj), context["title"])
|
||||
self.assertEqual(context["object"], self.obj)
|
||||
self.assertEqual(context["opts"], self.obj._meta)
|
||||
|
||||
def test_auditlog_history_view_extra_context(self):
|
||||
request = RequestFactory().get("/")
|
||||
request.user = self.user
|
||||
|
||||
extra_context = {"extra_key": "extra_value"}
|
||||
response = self.admin.auditlog_history_view(
|
||||
request, str(self.obj.pk), extra_context
|
||||
)
|
||||
|
||||
self.assertIn("extra_key", response.context_data)
|
||||
self.assertEqual(response.context_data["extra_key"], "extra_value")
|
||||
|
||||
def test_auditlog_history_view_template(self):
|
||||
request = RequestFactory().get("/")
|
||||
request.user = self.user
|
||||
|
||||
response = self.admin.auditlog_history_view(request, str(self.obj.pk))
|
||||
|
||||
self.assertEqual(response.template_name, self.admin.auditlog_history_template)
|
||||
|
||||
def test_auditlog_history_view_log_entries_ordering(self):
|
||||
self.obj.text = "First update"
|
||||
self.obj.save()
|
||||
self.obj.text = "Second update"
|
||||
self.obj.save()
|
||||
|
||||
request = RequestFactory().get("/")
|
||||
request.user = self.user
|
||||
|
||||
response = self.admin.auditlog_history_view(request, str(self.obj.pk))
|
||||
|
||||
log_entries = list(response.context_data["log_entries"])
|
||||
self.assertGreaterEqual(log_entries[0].timestamp, log_entries[1].timestamp)
|
||||
|
||||
def test_get_list_display_with_auditlog_link(self):
|
||||
self.admin.show_auditlog_history_link = True
|
||||
list_display = self.admin.get_list_display(RequestFactory().get("/"))
|
||||
|
||||
self.assertIn("auditlog_link", list_display)
|
||||
|
||||
self.admin.show_auditlog_history_link = False
|
||||
list_display = self.admin.get_list_display(RequestFactory().get("/"))
|
||||
|
||||
self.assertNotIn("auditlog_link", list_display)
|
||||
|
||||
def test_get_urls_includes_auditlog_url(self):
|
||||
urls = self.admin.get_urls()
|
||||
|
||||
self.assertGreater(len(urls), 0)
|
||||
|
||||
url_names = [
|
||||
url.name for url in urls if hasattr(url, "name") and url.name is not None
|
||||
]
|
||||
auditlog_urls = [name for name in url_names if "auditlog" in name]
|
||||
self.assertGreater(len(auditlog_urls), 0)
|
||||
|
||||
@patch("auditlog.mixins.reverse")
|
||||
def test_auditlog_link(self, mock_reverse):
|
||||
"""Test that auditlog_link method returns correct HTML link."""
|
||||
# Mock the reverse function to return a test URL
|
||||
expected_url = f"/admin/test_app/simplemodel/{self.obj.pk}/auditlog/"
|
||||
mock_reverse.return_value = expected_url
|
||||
|
||||
link_html = self.admin.auditlog_link(self.obj)
|
||||
|
||||
self.assertIsInstance(link_html, str)
|
||||
|
||||
self.assertIn("<a href=", link_html)
|
||||
self.assertIn("View</a>", link_html)
|
||||
|
||||
self.assertIn(expected_url, link_html)
|
||||
|
||||
opts = self.obj._meta
|
||||
expected_url_name = f"admin:{opts.app_label}_{opts.model_name}_auditlog"
|
||||
mock_reverse.assert_called_once_with(expected_url_name, args=[self.obj.pk])
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,5 +1,6 @@
|
|||
# Docs requirements
|
||||
django>=3.2,<3.3
|
||||
django>=4.2,<4.3
|
||||
sphinx
|
||||
sphinx_rtd_theme
|
||||
psycopg2-binary
|
||||
mysqlclient==2.2.5
|
||||
|
|
@ -19,10 +19,11 @@ from auditlog import __version__
|
|||
# documentation root, use os.path.abspath to make it absolute, like shown here.
|
||||
|
||||
# Add sources folder
|
||||
sys.path.insert(0, os.path.abspath("../../"))
|
||||
|
||||
sys.path.insert(0, os.path.abspath("../../auditlog_tests"))
|
||||
|
||||
# Setup Django for autodoc
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "auditlog_tests.test_settings")
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_settings")
|
||||
import django # noqa: E402
|
||||
|
||||
django.setup()
|
||||
|
|
|
|||
|
|
@ -11,10 +11,10 @@ The repository can be found at https://github.com/jazzband/django-auditlog/.
|
|||
|
||||
**Requirements**
|
||||
|
||||
- Python 3.8 or higher
|
||||
- Django 3.2, 4.2 and 5.0
|
||||
- Python 3.10 or higher
|
||||
- Django 4.2, 5.0, 5.1, and 5.2
|
||||
|
||||
Auditlog is currently tested with Python 3.8+ and Django 3.2, 4.2 and 5.0. The latest test report can be found
|
||||
Auditlog is currently tested with Python 3.10+ and Django 4.2, 5.0, 5.1, and 5.2. The latest test report can be found
|
||||
at https://github.com/jazzband/django-auditlog/actions.
|
||||
|
||||
Adding Auditlog to your Django application
|
||||
|
|
|
|||
|
|
@ -55,12 +55,28 @@ A DetailView utilizing the LogAccessMixin could look like the following example:
|
|||
|
||||
# View code goes here
|
||||
|
||||
You can also add log-access to function base views, as the following example illustrates:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from auditlog.signals import accessed
|
||||
|
||||
def profile_view(request, pk):
|
||||
## get the object you want to log access
|
||||
user = User.objects.get(pk=pk)
|
||||
|
||||
## log access
|
||||
accessed.send(user.__class__, instance=user)
|
||||
|
||||
# View code goes here
|
||||
...
|
||||
|
||||
|
||||
**Excluding fields**
|
||||
|
||||
Fields that are excluded will not trigger saving a new log entry and will not show up in the recorded changes.
|
||||
|
||||
To exclude specific fields from the log you can pass ``include_fields`` resp. ``exclude_fields`` to the ``register``
|
||||
To exclude specific fields from the log you can pass ``include_fields`` or ``exclude_fields`` to the ``register``
|
||||
method. If ``exclude_fields`` is specified the fields with the given names will not be included in the generated log
|
||||
entries. If ``include_fields`` is specified only the fields with the given names will be included in the generated log
|
||||
entries. Explicitly excluding fields through ``exclude_fields`` takes precedence over specifying which fields to
|
||||
|
|
@ -116,6 +132,37 @@ For example, to mask the field ``address``, use::
|
|||
|
||||
auditlog.register(MyModel, mask_fields=['address'])
|
||||
|
||||
You can also specify a custom masking function by passing ``mask_callable`` to the ``register``
|
||||
method. The ``mask_callable`` should be a dotted path to a function that takes a string and returns
|
||||
a masked version of that string.
|
||||
|
||||
For example, to use a custom masking function::
|
||||
|
||||
# In your_app/utils.py
|
||||
def custom_mask(value: str) -> str:
|
||||
return "****" + value[-4:] # Only show last 4 characters
|
||||
|
||||
# In your models.py
|
||||
auditlog.register(
|
||||
MyModel,
|
||||
mask_fields=['credit_card'],
|
||||
mask_callable='your_app.utils.custom_mask'
|
||||
)
|
||||
|
||||
Additionally, you can set a global default masking function that will be used when a model-specific
|
||||
mask_callable is not provided. To do this, add the following to your Django settings::
|
||||
|
||||
AUDITLOG_MASK_CALLABLE = 'your_app.utils.custom_mask'
|
||||
|
||||
The masking function priority is as follows:
|
||||
|
||||
1. Model-specific ``mask_callable`` if provided in ``register()``
|
||||
2. ``AUDITLOG_MASK_CALLABLE`` from settings if configured
|
||||
3. Default ``mask_str`` function which masks the first half of the string with asterisks
|
||||
|
||||
If ``mask_callable`` is not specified and no global default is configured, the default masking function will be used which masks
|
||||
the first half of the string with asterisks.
|
||||
|
||||
.. versionadded:: 2.0.0
|
||||
|
||||
Masking fields
|
||||
|
|
@ -206,7 +253,7 @@ It will be considered when ``AUDITLOG_INCLUDE_ALL_MODELS`` is `True`.
|
|||
|
||||
.. versionadded:: 3.0.0
|
||||
|
||||
**AUDITLOG_EXCLUDE_TRACKING_FIELDS**
|
||||
**AUDITLOG_DISABLE_REMOTE_ADDR**
|
||||
|
||||
When using "AuditlogMiddleware",
|
||||
the IP address is logged by default, you can use this setting
|
||||
|
|
@ -219,6 +266,28 @@ It will be considered when ``AUDITLOG_DISABLE_REMOTE_ADDR`` is `True`.
|
|||
|
||||
.. versionadded:: 3.0.0
|
||||
|
||||
**AUDITLOG_MASK_TRACKING_FIELDS**
|
||||
|
||||
You can use this setting to mask specific field values in all tracked models
|
||||
while still logging changes. This is useful when models contain sensitive fields
|
||||
like `password`, `api_key`, or `secret_token` that should not be logged
|
||||
in plain text but need to be auditable.
|
||||
|
||||
When a masked field changes, its value will be replaced with a masked
|
||||
representation (e.g., `****`) in the audit log instead of storing the actual value.
|
||||
|
||||
This setting will be applied only when ``AUDITLOG_INCLUDE_ALL_MODELS`` is `True`.
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
AUDITLOG_MASK_TRACKING_FIELDS = (
|
||||
"password",
|
||||
"api_key",
|
||||
"secret_token"
|
||||
)
|
||||
|
||||
.. versionadded:: 3.1.0
|
||||
|
||||
**AUDITLOG_EXCLUDE_TRACKING_MODELS**
|
||||
|
||||
You can use this setting to exclude models in registration process.
|
||||
|
|
@ -289,6 +358,127 @@ If the value is `None`, the default getter will be used.
|
|||
|
||||
.. versionadded:: 3.0.0
|
||||
|
||||
**AUDITLOG_CHANGE_DISPLAY_TRUNCATE_LENGTH**
|
||||
|
||||
This configuration variable defines the truncation behavior for strings in `changes_display_dict`, with a default value of `140` characters.
|
||||
|
||||
0: The entire string is truncated, resulting in an empty output.
|
||||
Positive values (e.g., 5): Truncates the string, keeping only the specified number of characters followed by an ellipsis (...) after the limit.
|
||||
Negative values: No truncation occurs, and the full string is displayed.
|
||||
|
||||
.. versionadded:: 3.1.0
|
||||
|
||||
**AUDITLOG_STORE_JSON_CHANGES**
|
||||
|
||||
This configuration variable defines whether to store changes as JSON.
|
||||
|
||||
This means that primitives such as booleans, integers, etc. will be represented using their JSON equivalents. For example, instead of storing
|
||||
`None` as a string, it will be stored as a JSON `null` in the `changes` field. Same goes for other primitives.
|
||||
|
||||
.. versionadded:: 3.2.0
|
||||
|
||||
**AUDITLOG_USE_BASE_MANAGER**
|
||||
|
||||
This configuration variable determines whether to use `base managers
|
||||
<https://docs.djangoproject.com/en/dev/topics/db/managers/#base-managers>`_ for
|
||||
tracked models instead of their default managers.
|
||||
|
||||
This setting can be useful for applications where the default manager behaviour
|
||||
hides some objects from the majority of ORM queries:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
class SecretManager(models.Manager):
|
||||
def get_queryset(self):
|
||||
return super().get_queryset().filter(is_secret=False)
|
||||
|
||||
|
||||
@auditlog.register()
|
||||
class SwappedManagerModel(models.Model):
|
||||
is_secret = models.BooleanField(default=False)
|
||||
name = models.CharField(max_length=255)
|
||||
|
||||
objects = SecretManager()
|
||||
|
||||
In this example, when ``AUDITLOG_USE_BASE_MANAGER`` is set to `True`, objects
|
||||
with the `is_secret` field set will be made visible to Auditlog. Otherwise you
|
||||
may see inaccurate data in log entries, recording changes to a seemingly
|
||||
"non-existent" object with empty fields.
|
||||
|
||||
.. versionadded:: 3.4.0
|
||||
|
||||
**AUDITLOG_LOGENTRY_MODEL**
|
||||
|
||||
This configuration variable allows you to specify a custom model to be used instead of the default
|
||||
:py:class:`auditlog.models.LogEntry` model for storing audit records.
|
||||
|
||||
By default, Auditlog stores change records in the built-in ``LogEntry`` model.
|
||||
If you need to store additional information in each log entry (for example, a user role, request metadata,
|
||||
or any other contextual data), you can define your own model by subclassing
|
||||
:py:class:`auditlog.models.AbstractLogEntry` and configure it using this setting.
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from django.db import models
|
||||
from auditlog.models import AbstractLogEntry
|
||||
|
||||
class CustomLogEntryModel(AbstractLogEntry):
|
||||
role = models.CharField(max_length=100, null=True, blank=True)
|
||||
|
||||
Then, in your project settings:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
AUDITLOG_LOGENTRY_MODEL = 'custom_log_app.CustomLogEntryModel'
|
||||
|
||||
Once defined, Auditlog will automatically use the specified model for all future log entries instead
|
||||
of the default one.
|
||||
|
||||
.. note::
|
||||
|
||||
- The custom model **must** inherit from :py:class:`auditlog.models.AbstractLogEntry`.
|
||||
- All fields and behaviors defined in :py:class:`AbstractLogEntry` should remain intact to ensure compatibility.
|
||||
- The app label and model name in ``AUDITLOG_LOGENTRY_MODEL`` must follow Django’s standard dotted notation
|
||||
(for example, ``"app_name.ModelName"``).
|
||||
|
||||
.. versionadded:: 3.5.0
|
||||
Custom LogEntry model configuration via ``AUDITLOG_LOGENTRY_MODEL``
|
||||
|
||||
**AUDITLOG_USE_FK_STRING_REPRESENTATION**
|
||||
|
||||
Determines how changes to foreign key fields are recorded in log entries.
|
||||
|
||||
When `True`, changes to foreign key fields are stored using the string representation of related objects.
|
||||
When `False` (default), the primary key of the related objects is stored instead.
|
||||
|
||||
Before version 2.2.0, foreign key changes were stored using the string representation of the related objects.
|
||||
Starting from version 2.2.0, the default behavior was updated to store the primary key of the related objects instead.
|
||||
|
||||
Before:
|
||||
|
||||
.. code-block:: json
|
||||
|
||||
{ "foreign_key_field": ["foo", "bar"] }
|
||||
|
||||
|
||||
After:
|
||||
|
||||
.. code-block:: json
|
||||
|
||||
{ "foreign_key_field": [1, 2] }
|
||||
|
||||
You can use this option to enable the legacy behavior.
|
||||
|
||||
.. warning::
|
||||
|
||||
This reintroduces a known issue https://github.com/jazzband/django-auditlog/issues/421
|
||||
Commission Error: Causes unnecessary LogEntries even though no update occurrs because the string representation in memory changed
|
||||
Omission Error: More common problem, a related object is updated to another object with the same string representation, no update is logged
|
||||
|
||||
Beware of these problem when enabling this setting.
|
||||
|
||||
.. versionadded:: 3.4.0
|
||||
|
||||
Actors
|
||||
------
|
||||
|
||||
|
|
@ -463,3 +653,26 @@ Django Admin integration
|
|||
|
||||
When ``auditlog`` is added to your ``INSTALLED_APPS`` setting a customized admin class is active providing an enhanced
|
||||
Django Admin interface for log entries.
|
||||
|
||||
Audit log history view
|
||||
----------------------
|
||||
|
||||
.. versionadded:: 3.2.2
|
||||
|
||||
Use ``AuditlogHistoryAdminMixin`` to add a "View" link in the admin changelist for accessing each object's audit history::
|
||||
|
||||
from auditlog.mixins import AuditlogHistoryAdminMixin
|
||||
|
||||
@admin.register(MyModel)
|
||||
class MyModelAdmin(AuditlogHistoryAdminMixin, admin.ModelAdmin):
|
||||
show_auditlog_history_link = True
|
||||
|
||||
The history page displays paginated log entries with user, timestamp, action, and field changes. Override
|
||||
``auditlog_history_template`` to customize the page layout.
|
||||
|
||||
The mixin provides the following configuration options:
|
||||
|
||||
- ``show_auditlog_history_link``: Set to ``True`` to display the "View" link in the admin changelist
|
||||
- ``auditlog_history_template``: Template to use for rendering the history page (default: ``auditlog/object_history.html``)
|
||||
- ``auditlog_history_per_page``: Number of log entries to display per page (default: 10)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
[tool.black]
|
||||
target-version = ["py38"]
|
||||
target-version = ["py39"]
|
||||
|
||||
[tool.isort]
|
||||
profile = "black"
|
||||
|
|
|
|||
15
runtests.py
15
runtests.py
|
|
@ -1,15 +0,0 @@
|
|||
#!/usr/bin/env python
|
||||
import os
|
||||
import sys
|
||||
|
||||
import django
|
||||
from django.conf import settings
|
||||
from django.test.utils import get_runner
|
||||
|
||||
if __name__ == "__main__":
|
||||
os.environ["DJANGO_SETTINGS_MODULE"] = "auditlog_tests.test_settings"
|
||||
django.setup()
|
||||
TestRunner = get_runner(settings)
|
||||
test_runner = TestRunner()
|
||||
failures = test_runner.run_tests(["auditlog_tests"])
|
||||
sys.exit(bool(failures))
|
||||
43
runtests.sh
Executable file
43
runtests.sh
Executable file
|
|
@ -0,0 +1,43 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
# Run tests against all supported databases
|
||||
set -e
|
||||
|
||||
# Default settings
|
||||
export TEST_DB_USER=${TEST_DB_USER:-testuser}
|
||||
export TEST_DB_PASS=${TEST_DB_PASS:-testpass}
|
||||
export TEST_DB_HOST=${TEST_DB_HOST:-127.0.0.1}
|
||||
export TEST_DB_NAME=${TEST_DB_NAME:-auditlog}
|
||||
|
||||
# Cleanup on exit
|
||||
trap 'docker compose -f auditlog_tests/docker-compose.yml down -v --remove-orphans 2>/dev/null || true' EXIT
|
||||
|
||||
echo "Starting containers..."
|
||||
docker compose -f auditlog_tests/docker-compose.yml up -d
|
||||
|
||||
echo "Waiting for databases..."
|
||||
echo "Waiting for PostgreSQL..."
|
||||
until docker compose -f auditlog_tests/docker-compose.yml exec postgres pg_isready -U ${TEST_DB_USER} -d auditlog >/dev/null 2>&1; do
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo "Waiting for MySQL..."
|
||||
|
||||
until docker compose -f auditlog_tests/docker-compose.yml exec mysql mysqladmin ping -h 127.0.0.1 -u ${TEST_DB_USER} --password=${TEST_DB_PASS} --silent >/dev/null 2>&1; do
|
||||
sleep 1
|
||||
done
|
||||
echo "Databases ready!"
|
||||
|
||||
# Run tests for each database
|
||||
for backend in sqlite3 postgresql mysql; do
|
||||
echo "Testing $backend..."
|
||||
export TEST_DB_BACKEND=$backend
|
||||
case $backend in
|
||||
postgresql) export TEST_DB_PORT=5432 ;;
|
||||
mysql) export TEST_DB_PORT=3306;;
|
||||
sqlite3) unset TEST_DB_PORT ;;
|
||||
esac
|
||||
tox
|
||||
done
|
||||
|
||||
echo "All tests completed!"
|
||||
12
setup.py
12
setup.py
|
|
@ -10,11 +10,13 @@ setup(
|
|||
name="django-auditlog",
|
||||
use_scm_version={"version_scheme": "post-release"},
|
||||
setup_requires=["setuptools_scm"],
|
||||
include_package_data=True,
|
||||
packages=[
|
||||
"auditlog",
|
||||
"auditlog.migrations",
|
||||
"auditlog.management",
|
||||
"auditlog.management.commands",
|
||||
"auditlog.templatetags",
|
||||
],
|
||||
url="https://github.com/jazzband/django-auditlog",
|
||||
project_urls={
|
||||
|
|
@ -27,20 +29,20 @@ setup(
|
|||
description="Audit log app for Django",
|
||||
long_description=long_description,
|
||||
long_description_content_type="text/markdown",
|
||||
python_requires=">=3.8",
|
||||
install_requires=["Django>=3.2", "python-dateutil>=2.7.0"],
|
||||
python_requires=">=3.10",
|
||||
install_requires=["Django>=4.2", "python-dateutil>=2.7.0"],
|
||||
zip_safe=False,
|
||||
classifiers=[
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.8",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Framework :: Django",
|
||||
"Framework :: Django :: 3.2",
|
||||
"Framework :: Django :: 4.2",
|
||||
"Framework :: Django :: 5.0",
|
||||
"Framework :: Django :: 5.1",
|
||||
"Framework :: Django :: 5.2",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
],
|
||||
)
|
||||
|
|
|
|||
54
tox.ini
54
tox.ini
|
|
@ -1,28 +1,39 @@
|
|||
[tox]
|
||||
envlist =
|
||||
{py38,py39,py310}-django32
|
||||
{py38,py39,py310,py311}-django42
|
||||
{py310,py311,py312}-django{50,main}
|
||||
py38-docs
|
||||
py38-lint
|
||||
{py312}-customlogmodel-django52
|
||||
{py310,py311}-django42
|
||||
{py310,py311,py312}-django50
|
||||
{py310,py311,py312,py313}-django51
|
||||
{py310,py311,py312,py313}-django52
|
||||
{py312,py313}-djangomain
|
||||
py310-docs
|
||||
py310-lint
|
||||
py310-checkmigrations
|
||||
|
||||
|
||||
[testenv]
|
||||
setenv =
|
||||
COVERAGE_FILE={toxworkdir}/.coverage.{envname}
|
||||
COVERAGE_FILE={toxworkdir}/.coverage.{envname}.{env:TEST_DB_BACKEND}
|
||||
customlogmodel: AUDITLOG_LOGENTRY_MODEL = custom_logentry_app.CustomLogEntryModel
|
||||
changedir = auditlog_tests
|
||||
commands =
|
||||
coverage run --source auditlog runtests.py
|
||||
coverage run --source auditlog ./manage.py test
|
||||
coverage xml
|
||||
deps =
|
||||
django32: Django>=3.2,<3.3
|
||||
django42: Django>=4.2,<4.3
|
||||
django50: Django>=5.0,<5.1
|
||||
django51: Django>=5.1,<5.2
|
||||
django52: Django>=5.2,<5.3
|
||||
djangomain: https://github.com/django/django/archive/main.tar.gz
|
||||
# Test requirements
|
||||
coverage
|
||||
codecov
|
||||
freezegun
|
||||
psycopg2-binary
|
||||
mysqlclient
|
||||
|
||||
passenv=
|
||||
TEST_DB_BACKEND
|
||||
TEST_DB_HOST
|
||||
TEST_DB_USER
|
||||
TEST_DB_PASS
|
||||
|
|
@ -30,26 +41,41 @@ passenv=
|
|||
TEST_DB_PORT
|
||||
|
||||
basepython =
|
||||
py313: python3.13
|
||||
py312: python3.12
|
||||
py311: python3.11
|
||||
py310: python3.10
|
||||
py39: python3.9
|
||||
py38: python3.8
|
||||
|
||||
[testenv:py38-docs]
|
||||
[testenv:py310-docs]
|
||||
changedir = docs/source
|
||||
deps = -rdocs/requirements.txt
|
||||
commands = sphinx-build -W -b html -d {envtmpdir}/doctrees . {envtmpdir}/html
|
||||
|
||||
[testenv:py38-lint]
|
||||
[testenv:py310-lint]
|
||||
deps = pre-commit
|
||||
commands =
|
||||
pre-commit run --all-files
|
||||
|
||||
[testenv:py310-checkmigrations]
|
||||
description = Check for missing migrations
|
||||
changedir = auditlog_tests
|
||||
deps =
|
||||
Django>=4.2
|
||||
psycopg2-binary
|
||||
mysqlclient
|
||||
passenv=
|
||||
TEST_DB_BACKEND
|
||||
TEST_DB_HOST
|
||||
TEST_DB_USER
|
||||
TEST_DB_PASS
|
||||
TEST_DB_NAME
|
||||
TEST_DB_PORT
|
||||
commands =
|
||||
python manage.py makemigrations --check --dry-run
|
||||
|
||||
[gh-actions]
|
||||
python =
|
||||
3.8: py38
|
||||
3.9: py39
|
||||
3.10: py310
|
||||
3.11: py311
|
||||
3.12: py312
|
||||
3.13: py313
|
||||
|
|
|
|||
Loading…
Reference in a new issue