mirror of
https://github.com/jazzband/django-auditlog.git
synced 2026-03-17 06:30:27 +00:00
Compare commits
No commits in common. "master" and "v3.1.0" have entirely different histories.
58 changed files with 373 additions and 3092 deletions
30
.github/actions/setup-python-deps/action.yml
vendored
30
.github/actions/setup-python-deps/action.yml
vendored
|
|
@ -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
|
||||
10
.github/workflows/release.yml
vendored
10
.github/workflows/release.yml
vendored
|
|
@ -11,14 +11,14 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@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: |
|
||||
|
|
|
|||
142
.github/workflows/test.yml
vendored
142
.github/workflows/test.yml
vendored
|
|
@ -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
1
.gitignore
vendored
|
|
@ -48,6 +48,7 @@ coverage.xml
|
|||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
59
CHANGELOG.md
59
CHANGELOG.md
|
|
@ -2,68 +2,10 @@
|
|||
|
||||
## Next Release
|
||||
|
||||
#### Fixes
|
||||
|
||||
- `KeyError` when calling `changes_str` on a log entry that tracks many-to-many field changes ([#798](https://github.com/jazzband/django-auditlog/pull/798))
|
||||
|
||||
## 3.4.1 (2025-12-13)
|
||||
|
||||
#### Fixes
|
||||
|
||||
- Fix AttributeError when AUDITLOG_LOGENTRY_MODEL is not set ([#789](https://github.com/jazzband/django-auditlog/pull/789))
|
||||
|
||||
## 3.4.0 (2025-12-04)
|
||||
|
||||
#### Improvements
|
||||
|
||||
- feat: Add CustomLogEntry model support and update tests ([#764)](https://github.com/jazzband/django-auditlog/pull/764))
|
||||
- feat: Add `AUDITLOG_USE_FK_STRING_REPRESENTATION` setting that controls how foreign key changes are represented ([#779)](https://github.com/jazzband/django-auditlog/pull/779))
|
||||
- Add `AUDITLOG_USE_BASE_MANAGER` setting to override default manager use ([#766](https://github.com/jazzband/django-auditlog/pull/766))
|
||||
- Drop 'Python 3.9' support ([#773](https://github.com/jazzband/django-auditlog/pull/773))
|
||||
|
||||
#### Fixes
|
||||
|
||||
- Make diffing more robust for polymorphic models ([#784](https://github.com/jazzband/django-auditlog/pull/784))
|
||||
- Amend setup configuration to include non-python package files ([#769](https://github.com/jazzband/django-auditlog/pull/769))
|
||||
|
||||
## 3.3.0 (2025-09-18)
|
||||
|
||||
#### Improvements
|
||||
|
||||
- CI: Extend CI and local test coverage to MySQL and SQLite ([#744](https://github.com/jazzband/django-auditlog/pull/744))
|
||||
- feat: Add audit log history view to Django Admin. ([#743](https://github.com/jazzband/django-auditlog/pull/743))
|
||||
- Fix Expression test compatibility for Django 6.0+ ([#759](https://github.com/jazzband/django-auditlog/pull/759))
|
||||
- Add I18N Support ([#762](https://github.com/jazzband/django-auditlog/pull/762))
|
||||
|
||||
## 3.2.1 (2025-07-03)
|
||||
|
||||
#### Improvements
|
||||
|
||||
- Confirm Django 5.2 support. ([#730](https://github.com/jazzband/django-auditlog/pull/730))
|
||||
|
||||
#### Fixes
|
||||
|
||||
- fix: ```AUDITLOG_STORE_JSON_CHANGES=True``` was not respected during updates and deletions. ([#732](https://github.com/jazzband/django-auditlog/pull/732))
|
||||
|
||||
## 3.2.0 (2025-06-26)
|
||||
|
||||
#### Improvements
|
||||
|
||||
- feat: Support storing JSON in the changes field when ```AUDITLOG_STORE_JSON_CHANGES``` is enabled. ([#719](https://github.com/jazzband/django-auditlog/pull/719))
|
||||
- feat: Added `AUDITLOG_MASK_CALLABLE` setting to allow custom masking functions ([#725](https://github.com/jazzband/django-auditlog/pull/725))
|
||||
|
||||
## 3.1.2 (2025-04-26)
|
||||
|
||||
#### Fixes
|
||||
|
||||
- CI: Pine twine and setuptools to fix release
|
||||
|
||||
## 3.1.1 (2025-04-16)
|
||||
|
||||
#### Fixes
|
||||
|
||||
- CI: Add required pkginfo to release workflow
|
||||
|
||||
## 3.1.0 (2025-04-15)
|
||||
|
||||
#### Improvements
|
||||
|
|
@ -80,6 +22,7 @@ via `AUDITLOG_MASK_TRACKING_FIELDS` setting. ([#702](https://github.com/jazzband
|
|||
|
||||
#### Fixes
|
||||
|
||||
- CI: Add required pkginfo to release workflow
|
||||
- fix: Use sender instead of receiver for `m2m_changed` signal ID to prevent duplicate entries for models that share a related model. ([#686](https://github.com/jazzband/django-auditlog/pull/686))
|
||||
- Fixed a problem when setting `Value(None)` in `JSONField` ([#646](https://github.com/jazzband/django-auditlog/pull/646))
|
||||
- Fixed a problem when setting `django.db.models.functions.Now()` in `DateTimeField` ([#635](https://github.com/jazzband/django-auditlog/pull/635))
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
recursive-include auditlog/templates *
|
||||
recursive-include auditlog/static *
|
||||
recursive-include auditlog/locale *
|
||||
42
Makefile
42
Makefile
|
|
@ -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}'
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
136
auditlog/diff.py
136
auditlog/diff.py
|
|
@ -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
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -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] "ログエントリ"
|
||||
Binary file not shown.
|
|
@ -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] "항목"
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
› <a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ opts.app_config.verbose_name }}</a>
|
||||
› <a href="{% url opts|admin_urlname:'changelist' %}">{{ module_name }}</a>
|
||||
› <a href="{% url opts|admin_urlname:'change' object.pk|admin_urlquote %}">{{ object|truncatewords:'18' }}</a>
|
||||
› {% translate 'Audit log' %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="content-main">
|
||||
<div id="auditlog-history" class="module">
|
||||
{% if log_entries %}
|
||||
<div class="auditlog-entries">
|
||||
{% for entry in log_entries %}
|
||||
{% include "auditlog/entry_detail.html" with entry=entry %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if pagination_required %}
|
||||
{% include "auditlog/pagination.html" with page_obj=log_entries page_var=page_var %}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p>{% trans 'No log entries found.' %}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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)
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CustomLogEntryConfig(AppConfig):
|
||||
name = "custom_logentry_app"
|
||||
|
|
@ -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,
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
@ -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])
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -3,4 +3,3 @@ django>=4.2,<4.3
|
|||
sphinx
|
||||
sphinx_rtd_theme
|
||||
psycopg2-binary
|
||||
mysqlclient==2.2.5
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 Django’s standard dotted notation
|
||||
(for example, ``"app_name.ModelName"``).
|
||||
|
||||
.. versionadded:: 3.5.0
|
||||
Custom LogEntry model configuration via ``AUDITLOG_LOGENTRY_MODEL``
|
||||
|
||||
**AUDITLOG_USE_FK_STRING_REPRESENTATION**
|
||||
|
||||
Determines how changes to foreign key fields are recorded in log entries.
|
||||
|
||||
When `True`, changes to foreign key fields are stored using the string representation of related objects.
|
||||
When `False` (default), the primary key of the related objects is stored instead.
|
||||
|
||||
Before version 2.2.0, foreign key changes were stored using the string representation of the related objects.
|
||||
Starting from version 2.2.0, the default behavior was updated to store the primary key of the related objects instead.
|
||||
|
||||
Before:
|
||||
|
||||
.. code-block:: json
|
||||
|
||||
{ "foreign_key_field": ["foo", "bar"] }
|
||||
|
||||
|
||||
After:
|
||||
|
||||
.. code-block:: json
|
||||
|
||||
{ "foreign_key_field": [1, 2] }
|
||||
|
||||
You can use this option to enable the legacy behavior.
|
||||
|
||||
.. warning::
|
||||
|
||||
This reintroduces a known issue https://github.com/jazzband/django-auditlog/issues/421
|
||||
Commission Error: Causes unnecessary LogEntries even though no update occurrs because the string representation in memory changed
|
||||
Omission Error: More common problem, a related object is updated to another object with the same string representation, no update is logged
|
||||
|
||||
Beware of these problem when enabling this setting.
|
||||
|
||||
.. versionadded:: 3.4.0
|
||||
|
||||
Actors
|
||||
------
|
||||
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
43
runtests.sh
43
runtests.sh
|
|
@ -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!"
|
||||
6
setup.py
6
setup.py
|
|
@ -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
36
tox.ini
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue