mirror of
https://github.com/jazzband/django-auditlog.git
synced 2026-03-16 22:20:26 +00:00
Compare commits
No commits in common. "master" and "v3.3.0" have entirely different histories.
39 changed files with 148 additions and 918 deletions
6
.github/workflows/release.yml
vendored
6
.github/workflows/release.yml
vendored
|
|
@ -11,14 +11,14 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
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') }}
|
||||
|
|
|
|||
14
.github/workflows/test.yml
vendored
14
.github/workflows/test.yml
vendored
|
|
@ -9,9 +9,9 @@ jobs:
|
|||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
||||
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Python and dependencies
|
||||
uses: ./.github/actions/setup-python-deps
|
||||
|
|
@ -35,7 +35,7 @@ jobs:
|
|||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
||||
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15
|
||||
|
|
@ -51,7 +51,7 @@ jobs:
|
|||
--health-timeout 5s
|
||||
--health-retries 10
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Python and dependencies
|
||||
uses: ./.github/actions/setup-python-deps
|
||||
|
|
@ -81,10 +81,10 @@ jobs:
|
|||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
||||
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
|
||||
services:
|
||||
mysql:
|
||||
image: mysql:8.4
|
||||
image: mysql:8.0
|
||||
env:
|
||||
MYSQL_DATABASE: auditlog
|
||||
MYSQL_USER: mysql
|
||||
|
|
@ -98,7 +98,7 @@ jobs:
|
|||
--health-timeout=5s
|
||||
--health-retries=20
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Install MySQL client libraries
|
||||
run: |
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
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.20.0
|
||||
hooks:
|
||||
- id: pyupgrade
|
||||
args: [--py310-plus]
|
||||
args: [--py39-plus]
|
||||
- repo: https://github.com/adamchainz/django-upgrade
|
||||
rev: 1.30.0
|
||||
rev: 1.27.0
|
||||
hooks:
|
||||
- id: django-upgrade
|
||||
args: [--target-version, "4.2"]
|
||||
|
|
|
|||
24
CHANGELOG.md
24
CHANGELOG.md
|
|
@ -2,30 +2,6 @@
|
|||
|
||||
## 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
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
recursive-include auditlog/templates *
|
||||
recursive-include auditlog/static *
|
||||
recursive-include auditlog/locale *
|
||||
|
|
@ -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`
|
||||
|
||||
|
|
|
|||
|
|
@ -62,17 +62,3 @@ settings.AUDITLOG_STORE_JSON_CHANGES = getattr(
|
|||
)
|
||||
|
||||
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 = getattr(user, "email", 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
|
||||
|
|
|
|||
|
|
@ -1,16 +1,14 @@
|
|||
import json
|
||||
from collections.abc import Callable
|
||||
from datetime import timezone
|
||||
from typing import Callable, 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 +21,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 +30,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
|
||||
|
||||
|
|
@ -74,7 +73,7 @@ def get_field_value(obj, field, use_json_for_changes=False):
|
|||
try:
|
||||
model_field = obj._meta.get_field(field.name)
|
||||
default = model_field.default
|
||||
except (AttributeError, FieldDoesNotExist):
|
||||
except AttributeError:
|
||||
default = NOT_PROVIDED
|
||||
|
||||
if default is NOT_PROVIDED:
|
||||
|
|
@ -106,11 +105,7 @@ def get_field_value(obj, field, use_json_for_changes=False):
|
|||
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")
|
||||
):
|
||||
elif (field.one_to_one or field.many_to_one) and hasattr(field, "rel_class"):
|
||||
value = smart_str(getattr(obj, field.get_attname()), strings_only=True)
|
||||
else:
|
||||
value = getattr(obj, field.name)
|
||||
|
|
@ -136,7 +131,7 @@ def is_primitive(obj) -> bool:
|
|||
return isinstance(obj, primitive_types)
|
||||
|
||||
|
||||
def get_mask_function(mask_callable: str | None = None) -> Callable[[str], str]:
|
||||
def get_mask_function(mask_callable: Optional[str] = None) -> Callable[[str], str]:
|
||||
"""
|
||||
Get the masking function to use based on the following priority:
|
||||
1. Model-specific mask_callable if provided
|
||||
|
|
@ -173,8 +168,8 @@ def mask_str(value: str) -> str:
|
|||
|
||||
|
||||
def model_instance_diff(
|
||||
old: Model | None,
|
||||
new: Model | None,
|
||||
old: Optional[Model],
|
||||
new: Optional[Model],
|
||||
fields_to_check=None,
|
||||
use_json_for_changes=False,
|
||||
):
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -14,12 +14,10 @@ from django.utils.text import capfirst
|
|||
from django.utils.timezone import is_aware, localtime
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from auditlog import get_logentry_model
|
||||
from auditlog.models import LogEntry
|
||||
from auditlog.render import get_field_verbose_name, render_logentry_changes_html
|
||||
from auditlog.signals import accessed
|
||||
|
||||
LogEntry = get_logentry_model()
|
||||
|
||||
MAX = 75
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,7 +23,6 @@ from django.utils import timezone as django_timezone
|
|||
from django.utils.encoding import smart_str
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from auditlog import get_logentry_model
|
||||
from auditlog.diff import get_mask_function
|
||||
|
||||
DEFAULT_OBJECT_REPR = "<error forming object repr>"
|
||||
|
|
@ -305,7 +303,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 +392,6 @@ class AbstractLogEntry(models.Model):
|
|||
objects = LogEntryManager()
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
get_latest_by = "timestamp"
|
||||
ordering = ["-timestamp"]
|
||||
verbose_name = _("log entry")
|
||||
|
|
@ -427,29 +424,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)
|
||||
|
||||
|
|
@ -545,7 +534,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 +553,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 +579,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 +622,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,7 +38,7 @@ 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,
|
||||
|
|
@ -57,9 +56,9 @@ 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,
|
||||
|
|
@ -78,7 +77,7 @@ 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,
|
||||
|
|
@ -95,7 +94,7 @@ 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,
|
||||
|
|
@ -123,7 +122,6 @@ 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
|
||||
|
|
@ -171,14 +169,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,14 @@ 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,
|
||||
mask_callable: Optional[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,
|
||||
):
|
||||
"""
|
||||
|
|
@ -259,7 +259,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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -20,7 +20,7 @@ services:
|
|||
mysql:
|
||||
container_name: auditlog_mysql
|
||||
platform: linux/x86_64
|
||||
image: mysql:8.4
|
||||
image: mysql:8.0
|
||||
restart: "no"
|
||||
environment:
|
||||
MYSQL_DATABASE: auditlog
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -430,40 +430,6 @@ 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)
|
||||
|
|
@ -487,15 +453,12 @@ 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)
|
||||
|
|
|
|||
|
|
@ -121,9 +121,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.")
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
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()
|
||||
from auditlog.models import LogEntry
|
||||
from auditlog.render import render_logentry_changes_html
|
||||
|
||||
|
||||
class RenderChangesTest(TestCase):
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ INSTALLED_APPS = [
|
|||
"django.contrib.admin",
|
||||
"django.contrib.staticfiles",
|
||||
"django.contrib.postgres",
|
||||
"custom_logentry_app",
|
||||
"auditlog",
|
||||
"test_app",
|
||||
]
|
||||
|
|
@ -29,14 +28,9 @@ MIDDLEWARE = [
|
|||
"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": {
|
||||
|
|
@ -106,5 +100,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")
|
||||
|
|
|
|||
|
|
@ -8,9 +8,7 @@ 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,10 +119,7 @@ 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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
from django.test import TestCase, override_settings
|
||||
from test_app.models import JSONModel, NullableFieldModel, RelatedModel, SimpleModel
|
||||
|
||||
from auditlog import get_logentry_model
|
||||
from auditlog.models import LogEntry
|
||||
from auditlog.registry import AuditlogModelRegistry
|
||||
|
||||
LogEntry = get_logentry_model()
|
||||
|
||||
|
||||
class JSONForChangesTest(TestCase):
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ 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
|
||||
|
|
@ -17,8 +17,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
|
||||
|
|
@ -47,10 +45,7 @@ from test_app.models import (
|
|||
NullableJSONModel,
|
||||
ProxyModel,
|
||||
RelatedModel,
|
||||
RelatedModelParent,
|
||||
ReusableThroughRelatedModel,
|
||||
SecretM2MModel,
|
||||
SecretRelatedModel,
|
||||
SerializeNaturalKeyRelatedModel,
|
||||
SerializeOnlySomeOfThisModel,
|
||||
SerializePrimaryKeyRelatedModel,
|
||||
|
|
@ -65,18 +60,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.context import disable_auditlog, set_actor
|
||||
from auditlog.diff import mask_str, 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 +123,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 +141,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 +171,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 +258,7 @@ class NoActorMixin:
|
|||
self.assertIsNone(log_entry.actor)
|
||||
|
||||
|
||||
class WithActorMixinBase:
|
||||
class WithActorMixin:
|
||||
sequence = itertools.count()
|
||||
|
||||
def setUp(self):
|
||||
|
|
@ -300,6 +277,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 +305,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 +369,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 +480,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 +711,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")
|
||||
|
|
@ -1399,7 +1358,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()), 34)
|
||||
|
||||
def test_register_models_register_model_with_attrs(self):
|
||||
self.test_auditlog._register_models(
|
||||
|
|
@ -1780,24 +1739,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):
|
||||
|
|
@ -1827,7 +1783,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 +1791,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 +1799,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 +2044,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 +2115,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()
|
||||
|
|
@ -2439,29 +2369,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 +2636,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 +2796,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
|
||||
|
|
@ -3025,139 +2931,6 @@ class ModelManagerTest(TestCase):
|
|||
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."""
|
||||
|
||||
|
|
@ -3215,91 +2988,3 @@ class CustomMaskModelTest(TestCase):
|
|||
"****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
|
||||
|
|
|
|||
|
|
@ -11,10 +11,10 @@ The repository can be found at https://github.com/jazzband/django-auditlog/.
|
|||
|
||||
**Requirements**
|
||||
|
||||
- Python 3.10 or higher
|
||||
- Python 3.9 or higher
|
||||
- Django 4.2, 5.0, 5.1, and 5.2
|
||||
|
||||
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, 5.1, and 5.2. The latest test report can be found
|
||||
at https://github.com/jazzband/django-auditlog/actions.
|
||||
|
||||
Adding Auditlog to your Django application
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -141,7 +141,7 @@ 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,
|
||||
|
|
@ -253,7 +253,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 +270,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
|
||||
|
||||
|
|
@ -377,108 +377,6 @@ This means that primitives such as booleans, integers, etc. will be represented
|
|||
|
||||
.. 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
|
||||
------
|
||||
|
||||
|
|
|
|||
5
setup.py
5
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",
|
||||
|
|
|
|||
19
tox.ini
19
tox.ini
|
|
@ -1,20 +1,17 @@
|
|||
[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
|
||||
changedir = auditlog_tests
|
||||
commands =
|
||||
coverage run --source auditlog ./manage.py test
|
||||
|
|
@ -45,18 +42,19 @@ 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 =
|
||||
|
|
@ -75,6 +73,7 @@ commands =
|
|||
|
||||
[gh-actions]
|
||||
python =
|
||||
3.9: py39
|
||||
3.10: py310
|
||||
3.11: py311
|
||||
3.12: py312
|
||||
|
|
|
|||
Loading…
Reference in a new issue