Compare commits

..

No commits in common. "master" and "v3.1.1" have entirely different histories.

58 changed files with 373 additions and 3087 deletions

View file

@ -1,30 +0,0 @@
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

View file

@ -11,14 +11,14 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v6
uses: actions/setup-python@v5
with:
python-version: '3.10'
python-version: '3.9'
- 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@v5
uses: actions/cache@v4
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==75.6.0 twine==6.0.1 wheel pkginfo
python -m pip install -U setuptools twine wheel pkginfo
- name: Build package
run: |

View file

@ -3,125 +3,67 @@ name: Test
on: [push, pull_request]
jobs:
test-sqlite:
name: SQLite • Python ${{ matrix.python-version }}
build:
runs-on: ubuntu-latest
strategy:
fail-fast: false
max-parallel: 5
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v6
python-version: ['3.9', '3.10', '3.11', '3.12', '3.13']
- name: Setup Python and dependencies
uses: ./.github/actions/setup-python-deps
with:
python-version: ${{ matrix.python-version }}
cache-key-prefix: sqlite3
- name: Run tests
env:
TEST_DB_BACKEND: sqlite3
run: tox -v
- name: Upload coverage
uses: codecov/codecov-action@v5
with:
name: SQLite • Python ${{ matrix.python-version }}
test-postgres:
name: PostgreSQL • Python ${{ matrix.python-version }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13"]
services:
postgres:
image: postgres:15
image: postgres:14
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: auditlog
POSTGRES_DB: postgres
ports:
- 5432/tcp
- 5432/tcp
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 10
--health-retries 5
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Setup Python and dependencies
uses: ./.github/actions/setup-python-deps
with:
python-version: ${{ matrix.python-version }}
cache-key-prefix: postgresql
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- 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: Get pip cache dir
id: pip-cache
run: |
echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT
run: tox -v
- name: Cache
uses: actions/cache@v4
with:
path: ${{ steps.pip-cache.outputs.dir }}
key:
-${{ matrix.python-version }}-v1-${{ hashFiles('**/setup.py') }}
restore-keys: |
-${{ matrix.python-version }}-v1-
- name: Upload coverage
uses: codecov/codecov-action@v5
with:
name: PostgreSQL • Python ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install --upgrade tox tox-gh-actions
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: Tox tests
run: |
tox -v
env:
TEST_DB_HOST: localhost
TEST_DB_USER: postgres
TEST_DB_PASS: postgres
TEST_DB_NAME: postgres
TEST_DB_PORT: ${{ job.services.postgres.ports[5432] }}
- name: 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 }}
- name: Upload coverage
uses: codecov/codecov-action@v5
with:
name: Python ${{ matrix.python-version }}

1
.gitignore vendored
View file

@ -48,6 +48,7 @@ coverage.xml
cover/
# Translations
*.mo
*.pot
# Django stuff:

View file

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

View file

@ -2,63 +2,11 @@
## 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)
## 3.1.01 (2025-04-16)
#### Fixes

View file

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

View file

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

View file

@ -1,24 +1,3 @@
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
)

View file

@ -4,11 +4,9 @@ from django.contrib import admin
from django.contrib.auth import get_user_model
from django.utils.translation import gettext_lazy as _
from auditlog import get_logentry_model
from auditlog.filters import CIDFilter, ResourceTypeFilter
from auditlog.mixins import LogEntryAdminMixin
LogEntry = get_logentry_model()
from auditlog.models import LogEntry
@admin.register(LogEntry)

View file

@ -1,4 +1,5 @@
from contextvars import ContextVar
from typing import Optional
from django.conf import settings
from django.http import HttpRequest
@ -7,7 +8,7 @@ from django.utils.module_loading import import_string
correlation_id = ContextVar("auditlog_correlation_id", default=None)
def set_cid(request: HttpRequest | None = None) -> None:
def set_cid(request: Optional[HttpRequest] = None) -> None:
"""
A function to read the cid from a request.
If the header is not in the request, then we set it to `None`.
@ -39,11 +40,11 @@ def set_cid(request: HttpRequest | None = None) -> None:
correlation_id.set(cid)
def _get_cid() -> str | None:
def _get_cid() -> Optional[str]:
return correlation_id.get()
def get_cid() -> str | None:
def get_cid() -> Optional[str]:
"""
Calls the cid getter function based on `settings.AUDITLOG_CID_GETTER`

View file

@ -55,24 +55,3 @@ settings.AUDITLOG_DISABLE_REMOTE_ADDR = getattr(
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
)

View file

@ -6,7 +6,7 @@ from functools import partial
from django.contrib.auth import get_user_model
from django.db.models.signals import pre_save
from auditlog import get_logentry_model
from auditlog.models import LogEntry
auditlog_value = ContextVar("auditlog_value")
auditlog_disabled = ContextVar("auditlog_disabled", default=False)
@ -14,33 +14,23 @@ auditlog_disabled = ContextVar("auditlog_disabled", default=False)
@contextlib.contextmanager
def set_actor(actor, remote_addr=None, remote_port=None):
"""Connect a signal receiver with current user attached."""
# Initialize thread local storage
context_data = {
"actor": actor,
"signal_duid": ("set_actor", time.time()),
"remote_addr": remote_addr,
"remote_port": remote_port,
}
return call_context_manager(context_data)
@contextlib.contextmanager
def set_extra_data(context_data):
return call_context_manager(context_data)
def call_context_manager(context_data):
"""Connect a signal receiver with current user attached."""
LogEntry = get_logentry_model()
# Initialize thread local storage
context_data["signal_duid"] = ("set_actor", time.time())
auditlog_value.set(context_data)
# Connect signal for automatic logging
set_extra_data = partial(
_set_extra_data,
set_actor = partial(
_set_actor,
user=actor,
signal_duid=context_data["signal_duid"],
)
pre_save.connect(
set_extra_data,
set_actor,
sender=LogEntry,
dispatch_uid=context_data["signal_duid"],
weak=False,
@ -57,26 +47,11 @@ def call_context_manager(context_data):
pre_save.disconnect(sender=LogEntry, dispatch_uid=auditlog["signal_duid"])
def _set_actor(auditlog, instance, sender):
LogEntry = get_logentry_model()
auth_user_model = get_user_model()
if "actor" in auditlog:
actor = auditlog.get("actor")
if (
sender == LogEntry
and isinstance(actor, auth_user_model)
and instance.actor is None
):
instance.actor = actor
instance.actor_email = getattr(actor, "email", None)
def _set_extra_data(sender, instance, signal_duid, **kwargs):
def _set_actor(user, sender, instance, signal_duid, **kwargs):
"""Signal receiver with extra 'user' and 'signal_duid' kwargs.
This function becomes a valid signal receiver when it is curried with the actor and a dispatch id.
"""
LogEntry = get_logentry_model()
try:
auditlog = auditlog_value.get()
except LookupError:
@ -84,15 +59,17 @@ def _set_extra_data(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.actor_email = hasattr(user, "email") and user.email or None
_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])
instance.remote_addr = auditlog["remote_addr"]
instance.remote_port = auditlog["remote_port"]
@contextlib.contextmanager

View file

@ -1,15 +1,12 @@
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 FieldDoesNotExist, ObjectDoesNotExist
from django.core.exceptions import ObjectDoesNotExist
from django.db.models import NOT_PROVIDED, DateTimeField, ForeignKey, JSONField, Model
from django.utils import timezone as django_timezone
from django.utils.encoding import smart_str
from django.utils.module_loading import import_string
from auditlog import get_logentry_model
def track_field(field):
@ -23,6 +20,7 @@ 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:
@ -31,7 +29,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 == get_logentry_model()
and field.remote_field.model == LogEntry
):
return False
@ -53,7 +51,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, use_json_for_changes=False):
def get_field_value(obj, field):
"""
Gets the value of a given model instance field.
@ -64,31 +62,11 @@ def get_field_value(obj, field, use_json_for_changes=False):
:return: The value of the field as a string.
:rtype: str
"""
def get_default_value():
"""
Attempts to get the default value for a field from the model's field definition.
:return: The default value of the field or None
"""
try:
model_field = obj._meta.get_field(field.name)
default = model_field.default
except (AttributeError, FieldDoesNotExist):
default = NOT_PROVIDED
if default is NOT_PROVIDED:
default = None
elif callable(default):
default = default()
return smart_str(default) if not use_json_for_changes else default
try:
if isinstance(field, DateTimeField):
# DateTimeFields are timezone-aware, so we need to convert the field
# to its naive form before we can accurately compare them for changes.
value = getattr(obj, field.name)
value = getattr(obj, field.name, None)
try:
value = field.to_python(value)
except TypeError:
@ -100,65 +78,29 @@ def get_field_value(obj, field, use_json_for_changes=False):
):
value = django_timezone.make_naive(value, timezone=timezone.utc)
elif isinstance(field, JSONField):
value = field.to_python(getattr(obj, field.name))
if not use_json_for_changes:
try:
value = json.dumps(value, sort_keys=True, cls=field.encoder)
except TypeError:
pass
elif (
not settings.AUDITLOG_USE_FK_STRING_REPRESENTATION
and (field.one_to_one or field.many_to_one)
and hasattr(field, "rel_class")
):
value = smart_str(getattr(obj, field.get_attname()), strings_only=True)
value = field.to_python(getattr(obj, field.name, None))
try:
value = json.dumps(value, sort_keys=True, cls=field.encoder)
except TypeError:
pass
elif (field.one_to_one or field.many_to_one) and hasattr(field, "rel_class"):
value = smart_str(
getattr(obj, field.get_attname(), None), strings_only=True
)
else:
value = 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()
value = smart_str(getattr(obj, field.name, None))
if type(value).__name__ == "__proxy__":
value = str(value)
except ObjectDoesNotExist:
value = (
field.default
if getattr(field, "default", NOT_PROVIDED) is not NOT_PROVIDED
else None
)
return value
def 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.
@ -173,10 +115,7 @@ def mask_str(value: str) -> str:
def model_instance_diff(
old: Model | None,
new: Model | None,
fields_to_check=None,
use_json_for_changes=False,
old: Optional[Model], new: Optional[Model], fields_to_check=None
):
"""
Calculates the differences between two model instances. One of the instances may be ``None``
@ -189,8 +128,6 @@ 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.
@ -252,30 +189,17 @@ def model_instance_diff(
fields = filtered_fields
for field in fields:
old_value = get_field_value(old, field, use_json_for_changes)
new_value = get_field_value(new, field, use_json_for_changes)
old_value = get_field_value(old, field)
new_value = get_field_value(new, field)
if old_value != new_value:
if model_fields and field.name in model_fields["mask_fields"]:
mask_func = get_mask_function(model_fields.get("mask_callable"))
diff[field.name] = (
mask_func(smart_str(old_value)),
mask_func(smart_str(new_value)),
mask_str(smart_str(old_value)),
mask_str(smart_str(new_value)),
)
else:
if not use_json_for_changes:
diff[field.name] = (smart_str(old_value), smart_str(new_value))
else:
# TODO: should we handle the case where the value is a django Model specifically?
# for example, could create a list of ids for ManyToMany fields
# this maintains the behavior of the original code
if not is_primitive(old_value):
old_value = smart_str(old_value)
if not is_primitive(new_value):
new_value = smart_str(new_value)
diff[field.name] = (old_value, new_value)
diff[field.name] = (smart_str(old_value), smart_str(new_value))
if len(diff) == 0:
diff = None

View file

@ -1,192 +0,0 @@
# 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] "ログエントリ"

View file

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

View file

@ -3,9 +3,7 @@ import datetime
from django.core.management.base import BaseCommand
from django.db import connection
from auditlog import get_logentry_model
LogEntry = get_logentry_model()
from auditlog.models import LogEntry
class Command(BaseCommand):
@ -85,7 +83,7 @@ class Command(BaseCommand):
class TruncateQuery:
SUPPORTED_VENDORS = ("postgresql", "mysql", "oracle", "microsoft")
SUPPORTED_VENDORS = ("postgresql", "mysql", "sqlite", "oracle", "microsoft")
@classmethod
def support_truncate_statement(cls, database_vendor) -> bool:

View file

@ -4,9 +4,7 @@ from django.conf import settings
from django.core.management import CommandError, CommandParser
from django.core.management.base import BaseCommand
from auditlog import get_logentry_model
LogEntry = get_logentry_model()
from auditlog.models import LogEntry
class Command(BaseCommand):
@ -126,13 +124,15 @@ class Command(BaseCommand):
def postgres():
with connection.cursor() as cursor:
cursor.execute(f"""
UPDATE {LogEntry._meta.db_table}
cursor.execute(
"""
UPDATE auditlog_logentry
SET changes="changes_text"::jsonb
WHERE changes_text IS NOT NULL
AND changes_text <> ''
AND changes IS NULL
""")
"""
)
return cursor.cursor.rowcount
if database == "postgres":

View file

@ -1,8 +1,10 @@
from typing import Optional
from django.conf import settings
from django.contrib.auth import get_user_model
from auditlog.cid import set_cid
from auditlog.context import set_extra_data
from auditlog.context import set_actor
class AuditlogMiddleware:
@ -37,7 +39,7 @@ class AuditlogMiddleware:
return remote_addr
@staticmethod
def _get_remote_port(request) -> int | None:
def _get_remote_port(request) -> Optional[int]:
remote_port = request.headers.get("X-Forwarded-Port", "")
try:
@ -54,17 +56,12 @@ class AuditlogMiddleware:
return user
return None
def get_extra_data(self, request):
context_data = {}
context_data["remote_addr"] = self._get_remote_addr(request)
context_data["remote_port"] = self._get_remote_port(request)
context_data["actor"] = self._get_actor(request)
return context_data
def __call__(self, request):
remote_addr = self._get_remote_addr(request)
remote_port = self._get_remote_port(request)
user = self._get_actor(request)
set_cid(request)
with set_extra_data(context_data=self.get_extra_data(request)):
with set_actor(actor=user, remote_addr=remote_addr, remote_port=remote_port):
return self.get_response(request)

View file

@ -1,25 +1,19 @@
from urllib.parse import unquote
from django import urls as urlresolvers
from django.conf import settings
from django.contrib import admin
from django.contrib.admin.views.main import PAGE_VAR
from django.core.exceptions import PermissionDenied
from django.core.exceptions import FieldDoesNotExist
from django.forms.utils import pretty_name
from django.http import HttpRequest
from django.template.response import TemplateResponse
from django.urls import path, reverse
from django.urls.exceptions import NoReverseMatch
from django.utils.html import format_html
from django.utils.text import capfirst
from django.utils.html import format_html, format_html_join
from django.utils.safestring import mark_safe
from django.utils.timezone import is_aware, localtime
from django.utils.translation import gettext_lazy as _
from auditlog import get_logentry_model
from auditlog.render import get_field_verbose_name, render_logentry_changes_html
from auditlog.models import LogEntry
from auditlog.registry import auditlog
from auditlog.signals import accessed
LogEntry = get_logentry_model()
MAX = 75
@ -74,7 +68,55 @@ class LogEntryAdminMixin:
@admin.display(description=_("Changes"))
def msg(self, obj):
return render_logentry_changes_html(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))
@admin.display(description="Correlation ID")
def cid_url(self, obj):
@ -85,95 +127,43 @@ 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"))

View file

@ -1,10 +1,9 @@
import ast
import contextlib
import json
from collections.abc import Callable
from copy import deepcopy
from datetime import timezone
from typing import Any
from typing import Any, Callable, Union
from dateutil import parser
from dateutil.tz import gettz
@ -24,8 +23,7 @@ from django.utils import timezone as django_timezone
from django.utils.encoding import smart_str
from django.utils.translation import gettext_lazy as _
from auditlog import get_logentry_model
from auditlog.diff import get_mask_function
from auditlog.diff import mask_str
DEFAULT_OBJECT_REPR = "<error forming object repr>"
@ -249,7 +247,7 @@ class LogEntryManager(models.Manager):
mask_fields = model_fields["mask_fields"]
if mask_fields:
data = self._mask_serialized_fields(data, mask_fields, model_fields)
data = self._mask_serialized_fields(data, mask_fields)
return data
@ -289,15 +287,14 @@ 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], model_fields: dict[str, Any]
self, data: dict[str, Any], mask_fields: list[str]
) -> dict[str, Any]:
all_field_data = data.pop("fields")
mask_func = get_mask_function(model_fields.get("mask_callable"))
masked_field_data = {}
for key, value in all_field_data.items():
if isinstance(value, str) and key in mask_fields:
masked_field_data[key] = mask_func(value)
masked_field_data[key] = mask_str(value)
else:
masked_field_data[key] = value
@ -305,7 +302,7 @@ class LogEntryManager(models.Manager):
return data
class AbstractLogEntry(models.Model):
class LogEntry(models.Model):
"""
Represents an entry in the audit log. The content type is saved along with the textual and numeric
(if available) primary key, as well as the textual representation of the object when it was saved.
@ -394,7 +391,6 @@ class AbstractLogEntry(models.Model):
objects = LogEntryManager()
class Meta:
abstract = True
get_latest_by = "timestamp"
ordering = ["-timestamp"]
verbose_name = _("log entry")
@ -427,29 +423,21 @@ class AbstractLogEntry(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 (non-m2m field changes only).
:param arrow: The string to place between each old and new value.
:param separator: The string to place between each field.
:return: A readable string of the changes in this log entry.
"""
substrings = []
for field, 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)
for field, values in self.changes_dict.items():
substring = "{field_name:s}{colon:s}{old:s}{arrow:s}{new:s}".format(
field_name=field,
colon=colon,
old=values[0],
arrow=arrow,
new=values[1],
)
substrings.append(substring)
return separator.join(substrings)
@ -466,17 +454,9 @@ class AbstractLogEntry(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 changes_dict.items():
for field_name, values in self.changes_dict.items():
# try to get the field attribute on the model
try:
field = model._meta.get_field(field_name)
@ -545,7 +525,7 @@ class AbstractLogEntry(models.Model):
return changes_display_dict
def _get_changes_display_for_fk_field(
self, field: models.ForeignKey | models.OneToOneField, value: Any
self, field: Union[models.ForeignKey, models.OneToOneField], value: Any
) -> str:
"""
:return: A string representing a given FK value and the field to which it belongs
@ -564,19 +544,12 @@ class AbstractLogEntry(models.Model):
return value
# Attempt to return the string representation of the object
try:
related_model_manager = _get_manager_from_settings(field.related_model)
return smart_str(related_model_manager.get(pk=pk_value))
return smart_str(field.related_model._default_manager.get(pk=pk_value))
# ObjectDoesNotExist will be raised if the object was deleted.
except ObjectDoesNotExist:
return f"Deleted '{field.related_model.__name__}' ({value})"
class 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
@ -597,7 +570,7 @@ class AuditlogHistoryField(GenericRelation):
"""
def __init__(self, pk_indexable=True, delete_related=False, **kwargs):
kwargs["to"] = get_logentry_model()
kwargs["to"] = LogEntry
if pk_indexable:
kwargs["object_id_field"] = "object_id"
@ -640,16 +613,3 @@ def _changes_func() -> Callable[[LogEntry], dict]:
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

View file

@ -2,10 +2,9 @@ from functools import wraps
from django.conf import settings
from auditlog import get_logentry_model
from auditlog.context import auditlog_disabled
from auditlog.diff import model_instance_diff
from auditlog.models import _get_manager_from_settings
from auditlog.models import LogEntry
from auditlog.signals import post_log, pre_log
@ -39,12 +38,11 @@ def log_create(sender, instance, created, **kwargs):
"""
if created:
_create_log_entry(
action=get_logentry_model().Action.CREATE,
action=LogEntry.Action.CREATE,
instance=instance,
sender=sender,
diff_old=None,
diff_new=instance,
use_json_for_changes=settings.AUDITLOG_STORE_JSON_CHANGES,
)
@ -57,15 +55,14 @@ def log_update(sender, instance, **kwargs):
"""
if not instance._state.adding and instance.pk is not None:
update_fields = kwargs.get("update_fields", None)
old = _get_manager_from_settings(sender).filter(pk=instance.pk).first()
old = sender._default_manager.filter(pk=instance.pk).first()
_create_log_entry(
action=get_logentry_model().Action.UPDATE,
action=LogEntry.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,
)
@ -78,12 +75,11 @@ def log_delete(sender, instance, **kwargs):
"""
if instance.pk is not None:
_create_log_entry(
action=get_logentry_model().Action.DELETE,
action=LogEntry.Action.DELETE,
instance=instance,
sender=sender,
diff_old=instance,
diff_new=None,
use_json_for_changes=settings.AUDITLOG_STORE_JSON_CHANGES,
)
@ -95,25 +91,17 @@ def log_access(sender, instance, **kwargs):
"""
if instance.pk is not None:
_create_log_entry(
action=get_logentry_model().Action.ACCESS,
action=LogEntry.Action.ACCESS,
instance=instance,
sender=sender,
diff_old=None,
diff_new=None,
force_log=True,
use_json_for_changes=settings.AUDITLOG_STORE_JSON_CHANGES,
)
def _create_log_entry(
action,
instance,
sender,
diff_old,
diff_new,
fields_to_check=None,
force_log=False,
use_json_for_changes=False,
action, instance, sender, diff_old, diff_new, fields_to_check=None, force_log=False
):
pre_log_results = pre_log.send(
sender,
@ -123,17 +111,13 @@ 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,
use_json_for_changes=use_json_for_changes,
diff_old, diff_new, fields_to_check=fields_to_check
)
if force_log or changes:
@ -157,7 +141,6 @@ 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
@ -171,14 +154,13 @@ 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 = model_manager.all()
changed_queryset = kwargs["model"]._default_manager.all()
else:
changed_queryset = model_manager.filter(pk__in=kwargs["pk_set"])
changed_queryset = kwargs["model"]._default_manager.filter(
pk__in=kwargs["pk_set"]
)
if action in ["post_add"]:
LogEntry.objects.log_m2m_changes(

View file

@ -1,7 +1,7 @@
import copy
from collections import defaultdict
from collections.abc import Callable, Collection, Iterable
from typing import Any
from collections.abc import Collection, Iterable
from typing import Any, Callable, Optional, Union
from django.apps import apps
from django.db.models import ManyToManyField, Model
@ -29,7 +29,7 @@ class AuditlogModelRegistry:
A registry that keeps track of the models that use Auditlog to track changes.
"""
DEFAULT_EXCLUDE_MODELS = (settings.AUDITLOG_LOGENTRY_MODEL, "admin.LogEntry")
DEFAULT_EXCLUDE_MODELS = ("auditlog.LogEntry", "admin.LogEntry")
def __init__(
self,
@ -38,7 +38,7 @@ class AuditlogModelRegistry:
delete: bool = True,
access: bool = True,
m2m: bool = True,
custom: dict[ModelSignal, Callable] | None = None,
custom: Optional[dict[ModelSignal, Callable]] = None,
):
from auditlog.receivers import log_access, log_create, log_delete, log_update
@ -62,14 +62,13 @@ class AuditlogModelRegistry:
def register(
self,
model: ModelBase = None,
include_fields: list[str] | None = None,
exclude_fields: list[str] | None = None,
mapping_fields: dict[str, str] | None = None,
mask_fields: list[str] | None = None,
mask_callable: str | None = None,
m2m_fields: Collection[str] | None = None,
include_fields: Optional[list[str]] = None,
exclude_fields: Optional[list[str]] = None,
mapping_fields: Optional[dict[str, str]] = None,
mask_fields: Optional[list[str]] = None,
m2m_fields: Optional[Collection[str]] = None,
serialize_data: bool = False,
serialize_kwargs: dict[str, Any] | None = None,
serialize_kwargs: Optional[dict[str, Any]] = None,
serialize_auditlog_fields_only: bool = False,
):
"""
@ -80,8 +79,6 @@ 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
@ -123,7 +120,6 @@ 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,
@ -176,7 +172,6 @@ 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):
@ -259,7 +254,7 @@ class AuditlogModelRegistry:
]
return exclude_models
def _register_models(self, models: Iterable[str | dict[str, Any]]) -> None:
def _register_models(self, models: Iterable[Union[str, dict[str, Any]]]) -> None:
models = copy.deepcopy(models)
for model in models:
if isinstance(model, str):
@ -376,9 +371,6 @@ 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)

View file

@ -1,95 +0,0 @@
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)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +1,6 @@
import uuid
from django.conf import settings
from django.contrib.postgres.fields import ArrayField
from django.core.serializers.json import DjangoJSONEncoder
from django.db import models
@ -20,7 +20,6 @@ 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)
@ -311,36 +310,26 @@ class CharfieldTextfieldModel(models.Model):
history = AuditlogHistoryField(delete_related=True)
# Only define PostgreSQL-specific models when ArrayField is available
if settings.TEST_DB_BACKEND == "postgresql":
from django.contrib.postgres.fields import ArrayField
class PostgresArrayFieldModel(models.Model):
"""
Test auditlog with Postgres's ArrayField
"""
class PostgresArrayFieldModel(models.Model):
"""
Test auditlog with Postgres's ArrayField
"""
RED = "r"
YELLOW = "y"
GREEN = "g"
RED = "r"
YELLOW = "y"
GREEN = "g"
STATUS_CHOICES = (
(RED, "Red"),
(YELLOW, "Yellow"),
(GREEN, "Green"),
)
STATUS_CHOICES = (
(RED, "Red"),
(YELLOW, "Yellow"),
(GREEN, "Green"),
)
arrayfield = ArrayField(
models.CharField(max_length=1, choices=STATUS_CHOICES), size=3
)
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
history = AuditlogHistoryField(delete_related=True)
class NoDeleteHistoryModel(models.Model):
@ -430,80 +419,28 @@ class SwappedManagerModel(models.Model):
objects = SecretManager()
def __str__(self):
return str(self.name)
@auditlog.register()
class SecretRelatedModel(RelatedModelParent):
"""
A RelatedModel, but with a foreign key to an object that could be secret.
"""
related = models.ForeignKey(
"SwappedManagerModel", related_name="related_models", on_delete=models.CASCADE
)
one_to_one = models.OneToOneField(
to="SwappedManagerModel",
on_delete=models.CASCADE,
related_name="reverse_one_to_one",
)
history = AuditlogHistoryField(delete_related=True)
def __str__(self):
return f"SecretRelatedModel #{self.pk} -> {self.related.id}"
class SecretM2MModel(models.Model):
m2m_related = models.ManyToManyField(
"SwappedManagerModel", related_name="m2m_related"
)
name = models.CharField(max_length=255)
def __str__(self):
return str(self.name)
class AutoManyRelatedModel(models.Model):
related = models.ManyToManyField(SimpleModel)
class CustomMaskModel(models.Model):
credit_card = models.CharField(max_length=16)
text = models.TextField()
history = AuditlogHistoryField(delete_related=True)
class NullableFieldModel(models.Model):
time = models.TimeField(null=True, blank=True)
optional_text = models.CharField(max_length=100, null=True, blank=True)
history = AuditlogHistoryField(delete_related=True)
auditlog.register(AltPrimaryKeyModel)
auditlog.register(UUIDPrimaryKeyModel)
auditlog.register(ModelPrimaryKeyModel)
auditlog.register(ProxyModel)
auditlog.register(RelatedModelParent)
auditlog.register(RelatedModel)
auditlog.register(ManyRelatedModel)
auditlog.register(ManyRelatedModel.recursive.through)
m2m_only_auditlog.register(ManyRelatedModel, m2m_fields={"related"})
m2m_only_auditlog.register(ModelForReusableThroughModel, m2m_fields={"related"})
m2m_only_auditlog.register(OtherModelForReusableThroughModel, m2m_fields={"related"})
m2m_only_auditlog.register(SecretM2MModel, m2m_fields={"m2m_related"})
m2m_only_auditlog.register(SwappedManagerModel, m2m_fields={"m2m_related"})
auditlog.register(SimpleExcludeModel, exclude_fields=["text"])
auditlog.register(SimpleMappingModel, mapping_fields={"sku": "Product No."})
auditlog.register(AdditionalDataIncludedModel)
auditlog.register(DateTimeFieldModel)
auditlog.register(ChoicesFieldModel)
auditlog.register(CharfieldTextfieldModel)
if settings.TEST_DB_BACKEND == "postgresql":
auditlog.register(PostgresArrayFieldModel)
auditlog.register(PostgresArrayFieldModel)
auditlog.register(NoDeleteHistoryModel)
auditlog.register(JSONModel)
auditlog.register(NullableJSONModel)
@ -524,9 +461,3 @@ 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)

View file

@ -6,13 +6,9 @@ from unittest import mock
import freezegun
from django.core.management import call_command
from django.db import connection
from django.test import TestCase, TransactionTestCase
from django.test.utils import skipIf
from test_app.models import SimpleModel
from auditlog.management.commands.auditlogflush import TruncateQuery
class AuditlogFlushTest(TestCase):
def setUp(self):
@ -121,9 +117,6 @@ class AuditlogFlushWithTruncateTest(TransactionTestCase):
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.")
@ -146,10 +139,6 @@ class AuditlogFlushWithTruncateTest(TransactionTestCase):
)
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.")
@ -163,10 +152,6 @@ class AuditlogFlushWithTruncateTest(TransactionTestCase):
)
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.")

View file

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

View file

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

View file

@ -8,8 +8,6 @@ DEBUG = True
SECRET_KEY = "test"
TEST_DB_BACKEND = os.getenv("TEST_DB_BACKEND", "sqlite3")
INSTALLED_APPS = [
"django.contrib.auth",
"django.contrib.contenttypes",
@ -17,72 +15,30 @@ INSTALLED_APPS = [
"django.contrib.sessions",
"django.contrib.admin",
"django.contrib.staticfiles",
"django.contrib.postgres",
"custom_logentry_app",
"auditlog",
"test_app",
]
MIDDLEWARE = [
"django.middleware.common.CommonMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"auditlog.middleware.AuditlogMiddleware",
]
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"),
}
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 = [
{
@ -106,5 +62,3 @@ ROOT_URLCONF = "test_app.urls"
USE_TZ = True
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
AUDITLOG_LOGENTRY_MODEL = os.environ.get("AUDITLOG_LOGENTRY_MODEL", "auditlog.LogEntry")

View file

@ -2,15 +2,11 @@ import json
from io import StringIO
from unittest.mock import patch
from django.conf import settings
from django.core.management import CommandError, call_command
from django.test import TestCase, override_settings
from django.test.utils import skipIf
from test_app.models import SimpleModel
from auditlog import get_logentry_model
LogEntry = get_logentry_model()
from auditlog.models import LogEntry
class TwoStepMigrationTest(TestCase):
@ -121,17 +117,13 @@ class AuditlogMigrateJsonTest(TestCase):
self.make_logentry()
# Act
LogEntry = get_logentry_model()
path = f"{LogEntry.__module__}.{LogEntry.__name__}.objects.bulk_update"
with patch(path) as bulk_update:
with patch("auditlog.models.LogEntry.objects.bulk_update") as bulk_update:
outbuf, errbuf = self.call_command("-b=1")
call_count = bulk_update.call_count
# Assert
self.assertEqual(call_count, 2)
@skipIf(settings.TEST_DB_BACKEND != "postgresql", "PostgreSQL-specific test")
def test_native_postgres(self):
# Arrange
log_entry = self.make_logentry()
@ -144,7 +136,6 @@ 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()

View file

@ -1,198 +0,0 @@
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"])

View file

@ -1,175 +0,0 @@
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])

View file

@ -4,12 +4,11 @@ import json
import random
import warnings
from datetime import timezone
from unittest import mock, skipIf
from unittest import mock
from unittest.mock import patch
import freezegun
from dateutil.tz import gettz
from django import VERSION as DJANGO_VERSION
from django.apps import apps
from django.conf import settings
from django.contrib.admin.sites import AdminSite
@ -17,8 +16,6 @@ from django.contrib.auth import get_user_model
from django.contrib.auth.models import AnonymousUser, User
from django.contrib.contenttypes.models import ContentType
from django.core import management
from django.core.exceptions import ImproperlyConfigured
from django.core.management import call_command
from django.db import models
from django.db.models import JSONField, Value
from django.db.models.functions import Now
@ -36,7 +33,6 @@ from test_app.models import (
AutoManyRelatedModel,
CharfieldTextfieldModel,
ChoicesFieldModel,
CustomMaskModel,
DateTimeFieldModel,
JSONModel,
ManyRelatedModel,
@ -45,12 +41,10 @@ from test_app.models import (
ModelPrimaryKeyModel,
NoDeleteHistoryModel,
NullableJSONModel,
PostgresArrayFieldModel,
ProxyModel,
RelatedModel,
RelatedModelParent,
ReusableThroughRelatedModel,
SecretM2MModel,
SecretRelatedModel,
SerializeNaturalKeyRelatedModel,
SerializeOnlySomeOfThisModel,
SerializePrimaryKeyRelatedModel,
@ -65,18 +59,15 @@ from test_app.models import (
UUIDPrimaryKeyModel,
)
from auditlog import get_logentry_model
from auditlog.admin import LogEntryAdmin
from auditlog.cid import get_cid
from auditlog.context import disable_auditlog, set_actor, set_extra_data
from auditlog.diff import mask_str, model_instance_diff
from auditlog.context import disable_auditlog, set_actor
from auditlog.diff import model_instance_diff
from auditlog.middleware import AuditlogMiddleware
from auditlog.models import DEFAULT_OBJECT_REPR
from auditlog.models import DEFAULT_OBJECT_REPR, LogEntry
from auditlog.registry import AuditlogModelRegistry, AuditLogRegistrationError, auditlog
from auditlog.signals import post_log, pre_log
LogEntry = get_logentry_model()
class SimpleModelTest(TestCase):
def setUp(self):
@ -131,11 +122,6 @@ class SimpleModelTest(TestCase):
{"boolean": ["False", "True"]},
msg="The change is correctly logged",
)
self.assertEqual(
history.changes_str,
"boolean: False → True",
msg="Changes string is correct",
)
def test_update_specific_field_supplied_via_save_method(self):
obj = self.obj
@ -154,11 +140,6 @@ class SimpleModelTest(TestCase):
"when using the `update_fields`."
),
)
self.assertEqual(
obj.history.get(action=LogEntry.Action.UPDATE).changes_str,
"boolean: False → True",
msg="Changes string is correct",
)
def test_django_update_fields_edge_cases(self):
"""
@ -189,11 +170,6 @@ class SimpleModelTest(TestCase):
{"boolean": ["False", "True"], "integer": ["None", "1"]},
msg="The 2 fields changed are correctly logged",
)
self.assertEqual(
obj.history.get(action=LogEntry.Action.UPDATE).changes_str,
"boolean: False → True; integer: None → 1",
msg="Changes string is correct",
)
def test_delete(self):
"""Deletion is logged correctly."""
@ -281,7 +257,7 @@ class NoActorMixin:
self.assertIsNone(log_entry.actor)
class WithActorMixinBase:
class WithActorMixin:
sequence = itertools.count()
def setUp(self):
@ -300,6 +276,10 @@ class WithActorMixinBase:
self.assertIsNotNone(auditlog_entries, msg="All auditlog entries are deleted.")
super().tearDown()
def make_object(self):
with set_actor(self.user):
return super().make_object()
def check_create_log_entry(self, obj, log_entry):
super().check_create_log_entry(obj, log_entry)
self.assertEqual(log_entry.actor, self.user)
@ -324,12 +304,6 @@ class WithActorMixinBase:
self.assertEqual(log_entry.actor_email, self.user.email)
class WithActorMixin(WithActorMixinBase):
def make_object(self):
with set_actor(self.user):
return super().make_object()
class AltPrimaryKeyModelBase(SimpleModelTest):
def make_object(self):
return AltPrimaryKeyModel.objects.create(
@ -394,10 +368,6 @@ class ModelPrimaryKeyModelWithActorTest(WithActorMixin, ModelPrimaryKeyModelBase
# Must inherit from TransactionTestCase to use self.assertNumQueries.
class ModelPrimaryKeyTest(TransactionTestCase):
def _fixture_teardown(self):
call_command("flush", verbosity=0, interactive=False, allow_cascade=True)
def test_get_pk_value(self):
"""
Test that the primary key can be retrieved without additional database queries.
@ -509,13 +479,6 @@ class ManyRelatedModelTest(TestCase):
},
)
def test_changes_str(self):
self.obj.related.add(self.related)
log_entry = self.obj.history.first()
self.assertEqual(
log_entry.changes_str, f"related: add {[smart_str(self.related)]}"
)
def test_adding_existing_related_obj(self):
self.obj.related.add(self.related)
log_entry = self.obj.history.first()
@ -747,11 +710,6 @@ class SimpleIncludeModelTest(TestCase):
{"label": ["Initial label", "New label"]},
msg="Only the label was logged, regardless of multiple entries in `update_fields`",
)
self.assertEqual(
obj.history.get(action=LogEntry.Action.UPDATE).changes_str,
"label: Initial label → New label",
msg="Changes string is correct",
)
def test_register_include_fields(self):
sim = SimpleIncludeModel(label="Include model", text="Looong text")
@ -839,57 +797,6 @@ class SimpleMappingModelTest(TestCase):
),
)
@override_settings(AUDITLOG_STORE_JSON_CHANGES=True)
def test_changes_display_dict_with_json_changes_and_simplemodel(self):
sm = SimpleModel(integer=37, text="my simple model instance")
sm.save()
self.assertEqual(
sm.history.latest().changes_display_dict["integer"][1],
"37",
)
self.assertEqual(
sm.history.latest().changes_display_dict["text"][1],
"my simple model instance",
)
@override_settings(AUDITLOG_STORE_JSON_CHANGES=True)
def test_register_mapping_fields_with_json_changes(self):
smm = SimpleMappingModel(
sku="ASD301301A6", vtxt="2.1.5", not_mapped="Not mapped"
)
smm.save()
self.assertEqual(
smm.history.latest().changes_dict["sku"][1],
"ASD301301A6",
msg="The diff function retains 'sku' and can be retrieved.",
)
self.assertEqual(
smm.history.latest().changes_dict["not_mapped"][1],
"Not mapped",
msg="The diff function does not map 'not_mapped' and can be retrieved.",
)
self.assertEqual(
smm.history.latest().changes_display_dict["Product No."][1],
"ASD301301A6",
msg="The diff function maps 'sku' as 'Product No.' and can be retrieved.",
)
self.assertEqual(
smm.history.latest().changes_display_dict["Version"][1],
"2.1.5",
msg=(
"The diff function maps 'vtxt' as 'Version' through verbose_name"
" setting on the model field and can be retrieved."
),
)
self.assertEqual(
smm.history.latest().changes_display_dict["not mapped"][1],
"Not mapped",
msg=(
"The diff function uses the django default verbose name for 'not_mapped'"
" and can be retrieved."
),
)
class SimpleMaskedFieldsModelTest(TestCase):
"""Log masked changes for fields in mask_fields"""
@ -903,21 +810,6 @@ class SimpleMaskedFieldsModelTest(TestCase):
msg="The diff function masks 'address' field.",
)
@override_settings(
AUDITLOG_MASK_CALLABLE="auditlog_tests.test_app.mask.custom_mask_str"
)
def test_global_mask_callable(self):
"""Test that global mask_callable from settings is used when model-specific one is not provided"""
instance = SimpleMaskedModel.objects.create(
address="1234567890123456", text="Some text"
)
self.assertEqual(
instance.history.latest().changes_dict["address"][1],
"****3456",
msg="The global masking function should be used when model-specific one is not provided",
)
class AdditionalDataModelTest(TestCase):
"""Log additional data if get_additional_data is defined in the model"""
@ -1271,30 +1163,15 @@ class DateTimeFieldModelTest(TestCase):
dtm.naive_dt = Now()
self.assertEqual(dtm.naive_dt, Now())
dtm.save()
# Django 6.0+ evaluates expressions during save (django ticket #27222)
if DJANGO_VERSION >= (6, 0, 0):
with self.subTest("After save Django 6.0+"):
self.assertIsInstance(dtm.naive_dt, datetime.datetime)
else:
with self.subTest("After save Django < 6.0"):
self.assertEqual(dtm.naive_dt, Now())
self.assertEqual(dtm.naive_dt, Now())
def test_json_field_value_none(self):
json_model = NullableJSONModel(json=Value(None, JSONField()))
json_model.save()
self.assertEqual(json_model.history.count(), 1)
changes_dict = json_model.history.latest().changes_dict
# Django 6.0+ evaluates expressions during save (django ticket #27222)
if DJANGO_VERSION >= (6, 0, 0):
with self.subTest("Django 6.0+"):
# Value(None) gets evaluated to "null"
self.assertEqual(changes_dict["json"][1], "null")
else:
with self.subTest("Django < 6.0"):
# Value(None) is preserved as string representation
self.assertEqual(changes_dict["json"][1], "Value(None)")
self.assertEqual(
json_model.history.latest().changes_dict["json"][1], "Value(None)"
)
class UnregisterTest(TestCase):
@ -1399,7 +1276,7 @@ class RegisterModelSettingsTest(TestCase):
self.assertTrue(self.test_auditlog.contains(SimpleExcludeModel))
self.assertTrue(self.test_auditlog.contains(ChoicesFieldModel))
self.assertEqual(len(self.test_auditlog.get_models()), 36)
self.assertEqual(len(self.test_auditlog.get_models()), 32)
def test_register_models_register_model_with_attrs(self):
self.test_auditlog._register_models(
@ -1773,6 +1650,47 @@ class CharFieldTextFieldModelTest(TestCase):
)
class PostgresArrayFieldModelTest(TestCase):
databases = "__all__"
def setUp(self):
self.obj = PostgresArrayFieldModel.objects.create(
arrayfield=[PostgresArrayFieldModel.RED, PostgresArrayFieldModel.GREEN],
)
@property
def latest_array_change(self):
return self.obj.history.latest().changes_display_dict["arrayfield"][1]
def test_changes_display_dict_arrayfield(self):
self.assertEqual(
self.latest_array_change,
"Red, Green",
msg="The human readable text for the two choices, 'Red, Green' is displayed.",
)
self.obj.arrayfield = [PostgresArrayFieldModel.GREEN]
self.obj.save()
self.assertEqual(
self.latest_array_change,
"Green",
msg="The human readable text 'Green' is displayed.",
)
self.obj.arrayfield = []
self.obj.save()
self.assertEqual(
self.latest_array_change,
"",
msg="The human readable text '' is displayed.",
)
self.obj.arrayfield = [PostgresArrayFieldModel.GREEN]
self.obj.save()
self.assertEqual(
self.latest_array_change,
"Green",
msg="The human readable text 'Green' is displayed.",
)
class AdminPanelTest(TestCase):
def setUp(self):
self.user = User.objects.create_user(
@ -1780,24 +1698,21 @@ class AdminPanelTest(TestCase):
)
self.site = AdminSite()
self.admin = LogEntryAdmin(LogEntry, self.site)
self.admin_path_prefix = (
f"admin/{LogEntry._meta.app_label}/{LogEntry._meta.model_name}"
)
with freezegun.freeze_time("2022-08-01 12:00:00Z"):
self.obj = SimpleModel.objects.create(text="For admin logentry test")
def test_auditlog_admin(self):
self.client.force_login(self.user)
log_pk = self.obj.history.latest().pk
res = self.client.get(f"/{self.admin_path_prefix}/")
res = self.client.get("/admin/auditlog/logentry/")
self.assertEqual(res.status_code, 200)
res = self.client.get(f"/{self.admin_path_prefix}/add/")
res = self.client.get("/admin/auditlog/logentry/add/")
self.assertEqual(res.status_code, 403)
res = self.client.get(f"/{self.admin_path_prefix}/{log_pk}/", follow=True)
res = self.client.get(f"/admin/auditlog/logentry/{log_pk}/", follow=True)
self.assertEqual(res.status_code, 200)
res = self.client.get(f"/{self.admin_path_prefix}/{log_pk}/delete/")
res = self.client.get(f"/admin/auditlog/logentry/{log_pk}/delete/")
self.assertEqual(res.status_code, 403)
res = self.client.get(f"/{self.admin_path_prefix}/{log_pk}/history/")
res = self.client.get(f"/admin/auditlog/logentry/{log_pk}/history/")
self.assertEqual(res.status_code, 200)
def test_created_timezone(self):
@ -1806,7 +1721,7 @@ class AdminPanelTest(TestCase):
for tz, timestamp in [
("UTC", "2022-08-01 12:00:00"),
("Asia/Tbilisi", "2022-08-01 16:00:00"),
("America/Argentina/Buenos_Aires", "2022-08-01 09:00:00"),
("America/Buenos_Aires", "2022-08-01 09:00:00"),
("Asia/Kathmandu", "2022-08-01 17:45:00"),
]:
with self.settings(TIME_ZONE=tz):
@ -1827,7 +1742,7 @@ class AdminPanelTest(TestCase):
def test_cid(self):
self.client.force_login(self.user)
expected_response = (
f'<a href="/{self.admin_path_prefix}/?cid=123" '
'<a href="/admin/auditlog/logentry/?cid=123" '
'title="Click to filter by records with this correlation id">123</a>'
)
@ -1835,7 +1750,7 @@ class AdminPanelTest(TestCase):
log_entry.cid = "123"
log_entry.save()
res = self.client.get(f"/{self.admin_path_prefix}/")
res = self.client.get("/admin/auditlog/logentry/")
self.assertEqual(res.status_code, 200)
self.assertIn(expected_response, res.rendered_content)
@ -1843,7 +1758,7 @@ class AdminPanelTest(TestCase):
log = self.obj.history.latest()
obj_pk = self.obj.pk
delete_log_request = RequestFactory().post(
f"/{self.admin_path_prefix}/{log.pk}/delete/"
f"/admin/auditlog/logentry/{log.pk}/delete/"
)
delete_log_request.resolver_match = resolve(delete_log_request.path)
delete_log_request.user = self.user
@ -2088,11 +2003,6 @@ class JSONModelTest(TestCase):
{"json": ["{}", '{"quantity": "1"}']},
msg="The change is correctly logged",
)
self.assertEqual(
history.changes_str,
'json: {}{"quantity": "1"}',
msg="Changes string is correct",
)
def test_update_with_no_changes(self):
"""No changes are logged."""
@ -2164,27 +2074,6 @@ class ModelInstanceDiffTest(TestCase):
model_instance_diff(simple2, simple1)
model_instance_diff(simple1, simple2)
def test_diff_polymorphic_models(self):
"""No error is raised when comparing parent/child for polymorphic models."""
# This tests that when a polymorphic model is compared to its parent,
# no FieldDoesNotExist errors are raised because those fields don't exist
# on the parent model.
# relation target
simple = SimpleModel()
simple.save()
# the parent model
related_parent = RelatedModelParent()
related_parent.save()
# the child model, with some fields that don't exist on the parent
related = RelatedModel(related=simple, one_to_one=simple)
related.save()
model_instance_diff(related, related_parent)
def test_object_repr_related_deleted(self):
"""No error is raised when __str__() loads a related object that has been deleted."""
simple = SimpleModel()
@ -2222,33 +2111,6 @@ class ModelInstanceDiffTest(TestCase):
msg="ObjectDoesNotExist should be handled",
)
def test_field_with_no_default_provided(self):
"""Field with no default (NOT_PROVIDED) should return None."""
first = SimpleModel(integer=1)
second = SimpleModel()
delattr(second, "integer")
changes = model_instance_diff(first, second)
self.assertEqual(
changes,
{"integer": ("1", "None")},
msg="field with no default should return None",
)
def test_field_with_callable_default(self):
first = SimpleModel(char="value")
second = SimpleModel()
delattr(second, "char")
changes = model_instance_diff(first, second)
self.assertEqual(
changes,
{"char": ("value", "default value")},
msg="callable default should be handled",
)
def test_diff_models_with_json_fields(self):
first = JSONModel.objects.create(
json={
@ -2439,29 +2301,6 @@ class TestRelatedDiffs(TestCase):
self.assertEqual(int(log_create.changes_dict["related"][1]), one_simple.id)
self.assertEqual(int(log_update.changes_dict["related"][1]), two_simple.id)
@override_settings(AUDITLOG_USE_FK_STRING_REPRESENTATION=True)
def test_string_representation_of_fk_changes(self):
"""FK changes should be stored using string representation when setting is enabled"""
t1 = self.test_date
with freezegun.freeze_time(t1):
simple = SimpleModel.objects.create(text="Test Foo")
two_simple = SimpleModel.objects.create(text="Test Bar")
instance = RelatedModel.objects.create(one_to_one=simple, related=simple)
t2 = self.test_date + datetime.timedelta(days=20)
with freezegun.freeze_time(t2):
instance.one_to_one = two_simple
instance.related = two_simple
instance.save()
self.assertEqual(instance.history.all().count(), 2)
log_update = instance.history.filter(timestamp=t2).first()
self.assertEqual(log_update.changes_dict["related"][0], "Test Foo")
self.assertEqual(log_update.changes_dict["related"][1], "Test Bar")
self.assertEqual(log_update.changes_dict["one_to_one"][0], "Test Foo")
self.assertEqual(log_update.changes_dict["one_to_one"][1], "Test Bar")
class TestModelSerialization(TestCase):
def setUp(self):
@ -2729,7 +2568,6 @@ class TestAccessLog(TestCase):
)
self.assertIsNone(log_entry.changes)
self.assertEqual(log_entry.changes_dict, {})
self.assertEqual(log_entry.changes_str, "")
class SignalTests(TestCase):
@ -2890,7 +2728,7 @@ class SignalTests(TestCase):
self.assertSignals(LogEntry.Action.DELETE)
@patch.object(LogEntry, "objects")
@patch("auditlog.receivers.LogEntry.objects")
def test_signals_errors(self, log_entry_objects_mock):
class CustomSignalError(BaseException):
pass
@ -3023,283 +2861,3 @@ class ModelManagerTest(TestCase):
log = LogEntry.objects.get_for_object(self.public).first()
self.assertEqual(log.action, LogEntry.Action.UPDATE)
self.assertEqual(log.changes_dict["name"], ["Public", "Updated"])
class BaseManagerSettingTest(TestCase):
"""
If the AUDITLOG_USE_BASE_MANAGER setting is enabled, "secret" objects
should be audited as if they were public, with full access to field
values.
"""
def test_use_base_manager_setting_update(self):
"""
Model update. The default False case is covered by test_update_secret.
"""
secret = SwappedManagerModel.objects.create(is_secret=True, name="Secret")
with override_settings(AUDITLOG_USE_BASE_MANAGER=True):
secret.name = "Updated"
secret.save()
log = LogEntry.objects.get_for_object(secret).first()
self.assertEqual(log.action, LogEntry.Action.UPDATE)
self.assertEqual(log.changes_dict["name"], ["Secret", "Updated"])
def test_use_base_manager_setting_related_model(self):
"""
When AUDITLOG_USE_BASE_MANAGER is enabled, related model changes that
are normally invisible to the default model manager should remain
visible and not refer to "deleted" objects.
"""
t1 = datetime.datetime(2025, 1, 1, 12, tzinfo=datetime.timezone.utc)
with (
override_settings(AUDITLOG_USE_BASE_MANAGER=False),
freezegun.freeze_time(t1),
):
public_one = SwappedManagerModel.objects.create(name="Public One")
secret_one = SwappedManagerModel.objects.create(
is_secret=True, name="Secret One"
)
instance_one = SecretRelatedModel.objects.create(
one_to_one=public_one,
related=secret_one,
)
log_one = instance_one.history.filter(timestamp=t1).first()
self.assertIsInstance(log_one, LogEntry)
display_dict = log_one.changes_display_dict
self.assertEqual(display_dict["related"][0], "None")
self.assertEqual(
display_dict["related"][1],
f"Deleted 'SwappedManagerModel' ({secret_one.id})",
"Default manager should have no visibility of secret object",
)
self.assertEqual(display_dict["one to one"][0], "None")
self.assertEqual(display_dict["one to one"][1], "Public One")
t2 = t1 + datetime.timedelta(days=20)
with (
override_settings(AUDITLOG_USE_BASE_MANAGER=True),
freezegun.freeze_time(t2),
):
public_two = SwappedManagerModel.objects.create(name="Public Two")
secret_two = SwappedManagerModel.objects.create(
is_secret=True, name="Secret Two"
)
instance_two = SecretRelatedModel.objects.create(
one_to_one=public_two,
related=secret_two,
)
log_two = instance_two.history.filter(timestamp=t2).first()
self.assertIsInstance(log_two, LogEntry)
display_dict = log_two.changes_display_dict
self.assertEqual(display_dict["related"][0], "None")
self.assertEqual(
display_dict["related"][1],
"Secret Two",
"Base manager should have full visibility of secret object",
)
self.assertEqual(display_dict["one to one"][0], "None")
self.assertEqual(display_dict["one to one"][1], "Public Two")
def test_use_base_manager_setting_changes(self):
"""
When AUDITLOG_USE_BASE_MANAGER is enabled, registered many-to-many model
changes that refer to an object hidden from the default model manager
should remain visible and be logged.
"""
with override_settings(AUDITLOG_USE_BASE_MANAGER=False):
obj_one = SwappedManagerModel.objects.create(
is_secret=True, name="Secret One"
)
m2m_one = SecretM2MModel.objects.create(name="M2M One")
m2m_one.m2m_related.add(obj_one)
self.assertIn(m2m_one, obj_one.m2m_related.all(), "Secret One sees M2M One")
self.assertNotIn(
obj_one, m2m_one.m2m_related.all(), "M2M One cannot see Secret One"
)
self.assertEqual(
0,
LogEntry.objects.get_for_object(m2m_one).count(),
"No update with default manager",
)
with override_settings(AUDITLOG_USE_BASE_MANAGER=True):
obj_two = SwappedManagerModel.objects.create(
is_secret=True, name="Secret Two"
)
m2m_two = SecretM2MModel.objects.create(name="M2M Two")
m2m_two.m2m_related.add(obj_two)
self.assertIn(m2m_two, obj_two.m2m_related.all(), "Secret Two sees M2M Two")
self.assertNotIn(
obj_two, m2m_two.m2m_related.all(), "M2M Two cannot see Secret Two"
)
self.assertEqual(
1,
LogEntry.objects.get_for_object(m2m_two).count(),
"Update logged with base manager",
)
log_entry = LogEntry.objects.get_for_object(m2m_two).first()
self.assertEqual(
log_entry.changes,
{
"m2m_related": {
"type": "m2m",
"operation": "add",
"objects": [smart_str(obj_two)],
}
},
)
self.assertEqual(
log_entry.changes_str, f"m2m_related: add {[smart_str(obj_two)]}"
)
class TestMaskStr(TestCase):
"""Test the mask_str function that masks sensitive data."""
def test_mask_str_empty(self):
self.assertEqual(mask_str(""), "")
def test_mask_str_single_char(self):
self.assertEqual(mask_str("a"), "a")
def test_mask_str_even_length(self):
self.assertEqual(mask_str("1234"), "**34")
def test_mask_str_odd_length(self):
self.assertEqual(mask_str("12345"), "**345")
def test_mask_str_long_text(self):
self.assertEqual(mask_str("confidential"), "******ential")
class CustomMaskModelTest(TestCase):
def test_custom_mask_function(self):
instance = CustomMaskModel.objects.create(
credit_card="1234567890123456", text="Some text"
)
self.assertEqual(
instance.history.latest().changes_dict["credit_card"][1],
"****3456",
msg="The custom masking function should mask all but last 4 digits",
)
def test_custom_mask_function_short_value(self):
"""Test that custom masking function handles short values correctly"""
instance = CustomMaskModel.objects.create(credit_card="123", text="Some text")
self.assertEqual(
instance.history.latest().changes_dict["credit_card"][1],
"123",
msg="The custom masking function should not mask values shorter than 4 characters",
)
def test_custom_mask_function_serialized_data(self):
instance = CustomMaskModel.objects.create(
credit_card="1234567890123456", text="Some text"
)
log = instance.history.latest()
self.assertTrue(isinstance(log, LogEntry))
self.assertEqual(log.action, LogEntry.Action.CREATE)
# Update to trigger serialization
instance.credit_card = "9876543210987654"
instance.save()
log = instance.history.latest()
self.assertEqual(
log.changes_dict["credit_card"][1],
"****7654",
msg="The custom masking function should be used in serialized data",
)
class WithExtraDataMixin(WithActorMixinBase):
def get_context_data(self):
return {}
def make_object(self):
with set_extra_data(context_data=self.get_context_data()):
return super().make_object()
class ExtraDataTest(WithExtraDataMixin, SimpleModelTest):
def get_context_data(self):
return {
"actor": self.user,
}
class ExtraDataWithRoleTest(WithExtraDataMixin, SimpleModelTest):
def get_context_data(self):
return {
"actor": self.user,
"role": "admin",
}
@skipIf(
settings.AUDITLOG_LOGENTRY_MODEL == "auditlog.LogEntry",
"Do not run on defualt log entry model",
)
def test_extra_data_role(self):
log = self.obj.history.first()
self.assertEqual(log.role, "admin")
class ExtraDataWithRoleLazyLoadTest(WithExtraDataMixin, SimpleModelTest):
def get_context_data(self):
return {
"actor": self.user,
"role": lambda: "admin",
}
@skipIf(
settings.AUDITLOG_LOGENTRY_MODEL == "auditlog.LogEntry",
"Do not run on defualt log entry model",
)
def test_extra_data_role(self):
log = self.obj.history.first()
self.assertEqual(log.role, "admin")
class GetLogEntryModelTest(TestCase):
"""Test the get_logentry_model function."""
def get_model_name(self):
model = get_logentry_model()
return f"{model._meta.app_label}.{model._meta.object_name}"
def test_logentry_model(self):
self.assertEqual(self.get_model_name(), settings.AUDITLOG_LOGENTRY_MODEL)
@override_settings(AUDITLOG_LOGENTRY_MODEL="LogEntry")
def test_invalid_logentry_model_name(self):
with self.assertRaises(ImproperlyConfigured):
get_logentry_model()
@override_settings(AUDITLOG_LOGENTRY_MODEL="test_app2.LogEntry")
def test_invalid_appname(self):
with self.assertRaises(ImproperlyConfigured):
get_logentry_model()
def test_logentry_model_default_when_setting_missing(self):
"""Regression test for issue #788: AttributeError when AUDITLOG_LOGENTRY_MODEL is not set."""
# Save and remove the setting to simulate the bug condition
original_value = getattr(settings, "AUDITLOG_LOGENTRY_MODEL", None)
if hasattr(settings, "AUDITLOG_LOGENTRY_MODEL"):
delattr(settings, "AUDITLOG_LOGENTRY_MODEL")
try:
# This should NOT raise AttributeError - it should use the default
model = get_logentry_model()
self.assertEqual(
f"{model._meta.app_label}.{model._meta.object_name}",
"auditlog.LogEntry",
)
finally:
# Restore the original setting
if original_value is not None:
settings.AUDITLOG_LOGENTRY_MODEL = original_value

View file

@ -3,4 +3,3 @@ django>=4.2,<4.3
sphinx
sphinx_rtd_theme
psycopg2-binary
mysqlclient==2.2.5

View file

@ -11,10 +11,10 @@ The repository can be found at https://github.com/jazzband/django-auditlog/.
**Requirements**
- Python 3.10 or higher
- Django 4.2, 5.0, 5.1, and 5.2
- Python 3.9 or higher
- Django 4.2, 5.0 and 5.1
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
Auditlog is currently tested with Python 3.9+ and Django 4.2, 5.0 and 5.1. The latest test report can be found
at https://github.com/jazzband/django-auditlog/actions.
Adding Auditlog to your Django application

View file

@ -76,7 +76,7 @@ You can also add log-access to function base views, as the following example ill
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`` or ``exclude_fields`` to the ``register``
To exclude specific fields from the log you can pass ``include_fields`` resp. ``exclude_fields`` to the ``register``
method. If ``exclude_fields`` is specified the fields with the given names will not be included in the generated log
entries. If ``include_fields`` is specified only the fields with the given names will be included in the generated log
entries. Explicitly excluding fields through ``exclude_fields`` takes precedence over specifying which fields to
@ -132,37 +132,6 @@ 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
@ -253,7 +222,7 @@ It will be considered when ``AUDITLOG_INCLUDE_ALL_MODELS`` is `True`.
.. versionadded:: 3.0.0
**AUDITLOG_DISABLE_REMOTE_ADDR**
**AUDITLOG_EXCLUDE_TRACKING_FIELDS**
When using "AuditlogMiddleware",
the IP address is logged by default, you can use this setting
@ -270,13 +239,13 @@ It will be considered when ``AUDITLOG_DISABLE_REMOTE_ADDR`` is `True`.
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
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`.
This setting will be applied only when `AUDITLOG_INCLUDE_ALL_MODELS`` is `True`.
.. code-block:: python
@ -368,117 +337,6 @@ 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 Djangos 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
------
@ -653,26 +511,3 @@ 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)

View file

@ -1,43 +0,0 @@
#!/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!"

View file

@ -10,13 +10,11 @@ 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={
@ -29,11 +27,12 @@ setup(
description="Audit log app for Django",
long_description=long_description,
long_description_content_type="text/markdown",
python_requires=">=3.10",
python_requires=">=3.9",
install_requires=["Django>=4.2", "python-dateutil>=2.7.0"],
zip_safe=False,
classifiers=[
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
@ -42,7 +41,6 @@ setup(
"Framework :: Django :: 4.2",
"Framework :: Django :: 5.0",
"Framework :: Django :: 5.1",
"Framework :: Django :: 5.2",
"License :: OSI Approved :: MIT License",
],
)

36
tox.ini
View file

@ -1,20 +1,16 @@
[tox]
envlist =
{py312}-customlogmodel-django52
{py310,py311}-django42
{py39,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
py39-docs
py39-lint
py39-checkmigrations
[testenv]
setenv =
COVERAGE_FILE={toxworkdir}/.coverage.{envname}.{env:TEST_DB_BACKEND}
customlogmodel: AUDITLOG_LOGENTRY_MODEL = custom_logentry_app.CustomLogEntryModel
COVERAGE_FILE={toxworkdir}/.coverage.{envname}
changedir = auditlog_tests
commands =
coverage run --source auditlog ./manage.py test
@ -23,17 +19,13 @@ deps =
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
@ -45,36 +37,30 @@ basepython =
py312: python3.12
py311: python3.11
py310: python3.10
py39: python3.9
[testenv:py310-docs]
[testenv:py39-docs]
changedir = docs/source
deps = -rdocs/requirements.txt
commands = sphinx-build -W -b html -d {envtmpdir}/doctrees . {envtmpdir}/html
[testenv:py310-lint]
[testenv:py39-lint]
deps = pre-commit
commands =
pre-commit run --all-files
[testenv:py310-checkmigrations]
[testenv:py39-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
psycopg2
commands =
python manage.py makemigrations --check --dry-run
[gh-actions]
python =
3.9: py39
3.10: py310
3.11: py311
3.12: py312