mirror of
https://github.com/jazzband/django-auditlog.git
synced 2026-03-16 22:20:26 +00:00
Merge branch 'master' into logentry-custom-model
This commit is contained in:
commit
7e4ee64140
18 changed files with 313 additions and 57 deletions
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
|
|
@ -18,7 +18,7 @@ jobs:
|
|||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.9'
|
||||
python-version: '3.10'
|
||||
|
||||
- name: Get pip cache dir
|
||||
id: pip-cache
|
||||
|
|
|
|||
6
.github/workflows/test.yml
vendored
6
.github/workflows/test.yml
vendored
|
|
@ -9,7 +9,7 @@ jobs:
|
|||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
|
||||
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
|
|
@ -35,7 +35,7 @@ jobs:
|
|||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
|
||||
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15
|
||||
|
|
@ -81,7 +81,7 @@ jobs:
|
|||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
|
||||
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
||||
services:
|
||||
mysql:
|
||||
image: mysql:8.0
|
||||
|
|
|
|||
|
|
@ -4,24 +4,24 @@ repos:
|
|||
rev: 25.9.0
|
||||
hooks:
|
||||
- id: black
|
||||
language_version: python3.9
|
||||
language_version: python3.10
|
||||
args:
|
||||
- "--target-version"
|
||||
- "py39"
|
||||
- "py310"
|
||||
- repo: https://github.com/PyCQA/flake8
|
||||
rev: "7.3.0"
|
||||
hooks:
|
||||
- id: flake8
|
||||
args: ["--max-line-length", "110"]
|
||||
- repo: https://github.com/PyCQA/isort
|
||||
rev: 6.1.0
|
||||
rev: 7.0.0
|
||||
hooks:
|
||||
- id: isort
|
||||
- repo: https://github.com/asottile/pyupgrade
|
||||
rev: v3.20.0
|
||||
rev: v3.21.0
|
||||
hooks:
|
||||
- id: pyupgrade
|
||||
args: [--py39-plus]
|
||||
args: [--py310-plus]
|
||||
- repo: https://github.com/adamchainz/django-upgrade
|
||||
rev: 1.29.0
|
||||
hooks:
|
||||
|
|
|
|||
|
|
@ -2,6 +2,11 @@
|
|||
|
||||
## Next Release
|
||||
|
||||
#### Improvements
|
||||
|
||||
- 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))
|
||||
|
||||
## 3.3.0 (2025-09-18)
|
||||
|
||||
#### Improvements
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
from contextvars import ContextVar
|
||||
from typing import Optional
|
||||
|
||||
from django.conf import settings
|
||||
from django.http import HttpRequest
|
||||
|
|
@ -8,7 +7,7 @@ from django.utils.module_loading import import_string
|
|||
correlation_id = ContextVar("auditlog_correlation_id", default=None)
|
||||
|
||||
|
||||
def set_cid(request: Optional[HttpRequest] = None) -> None:
|
||||
def set_cid(request: HttpRequest | None = None) -> None:
|
||||
"""
|
||||
A function to read the cid from a request.
|
||||
If the header is not in the request, then we set it to `None`.
|
||||
|
|
@ -40,11 +39,11 @@ def set_cid(request: Optional[HttpRequest] = None) -> None:
|
|||
correlation_id.set(cid)
|
||||
|
||||
|
||||
def _get_cid() -> Optional[str]:
|
||||
def _get_cid() -> str | None:
|
||||
return correlation_id.get()
|
||||
|
||||
|
||||
def get_cid() -> Optional[str]:
|
||||
def get_cid() -> str | None:
|
||||
"""
|
||||
Calls the cid getter function based on `settings.AUDITLOG_CID_GETTER`
|
||||
|
||||
|
|
|
|||
|
|
@ -66,3 +66,8 @@ settings.AUDITLOG_MASK_CALLABLE = getattr(settings, "AUDITLOG_MASK_CALLABLE", No
|
|||
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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
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 ObjectDoesNotExist
|
||||
|
|
@ -132,7 +132,7 @@ def is_primitive(obj) -> bool:
|
|||
return isinstance(obj, primitive_types)
|
||||
|
||||
|
||||
def get_mask_function(mask_callable: Optional[str] = None) -> Callable[[str], str]:
|
||||
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
|
||||
|
|
@ -169,8 +169,8 @@ def mask_str(value: str) -> str:
|
|||
|
||||
|
||||
def model_instance_diff(
|
||||
old: Optional[Model],
|
||||
new: Optional[Model],
|
||||
old: Model | None,
|
||||
new: Model | None,
|
||||
fields_to_check=None,
|
||||
use_json_for_changes=False,
|
||||
):
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
from typing import Optional
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
|
|
@ -39,7 +37,7 @@ class AuditlogMiddleware:
|
|||
return remote_addr
|
||||
|
||||
@staticmethod
|
||||
def _get_remote_port(request) -> Optional[int]:
|
||||
def _get_remote_port(request) -> int | None:
|
||||
remote_port = request.headers.get("X-Forwarded-Port", "")
|
||||
|
||||
try:
|
||||
|
|
@ -70,4 +68,3 @@ class AuditlogMiddleware:
|
|||
|
||||
with set_extra_data(context_data=self.get_extra_data(request)):
|
||||
return self.get_response(request)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import ast
|
||||
import contextlib
|
||||
import json
|
||||
from collections.abc import Callable
|
||||
from copy import deepcopy
|
||||
from datetime import timezone
|
||||
from typing import Any, Callable, Union
|
||||
from typing import Any
|
||||
|
||||
from dateutil import parser
|
||||
from dateutil.tz import gettz
|
||||
|
|
@ -536,7 +537,7 @@ class AbstractLogEntry(models.Model):
|
|||
return changes_display_dict
|
||||
|
||||
def _get_changes_display_for_fk_field(
|
||||
self, field: Union[models.ForeignKey, models.OneToOneField], value: Any
|
||||
self, field: models.ForeignKey | models.OneToOneField, value: Any
|
||||
) -> str:
|
||||
"""
|
||||
:return: A string representing a given FK value and the field to which it belongs
|
||||
|
|
@ -555,7 +556,9 @@ class AbstractLogEntry(models.Model):
|
|||
return value
|
||||
# Attempt to return the string representation of the object
|
||||
try:
|
||||
return smart_str(field.related_model._default_manager.get(pk=pk_value))
|
||||
related_model_manager = _get_manager_from_settings(field.related_model)
|
||||
|
||||
return smart_str(related_model_manager.get(pk=pk_value))
|
||||
# ObjectDoesNotExist will be raised if the object was deleted.
|
||||
except ObjectDoesNotExist:
|
||||
return f"Deleted '{field.related_model.__name__}' ({value})"
|
||||
|
|
@ -629,3 +632,16 @@ 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
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ from django.conf import settings
|
|||
from auditlog import get_logentry_model
|
||||
from auditlog.context import auditlog_disabled
|
||||
from auditlog.diff import model_instance_diff
|
||||
from auditlog.models import _get_manager_from_settings
|
||||
from auditlog.signals import post_log, pre_log
|
||||
|
||||
|
||||
|
|
@ -56,7 +57,7 @@ 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 = sender._default_manager.filter(pk=instance.pk).first()
|
||||
old = _get_manager_from_settings(sender).filter(pk=instance.pk).first()
|
||||
_create_log_entry(
|
||||
action=get_logentry_model().Action.UPDATE,
|
||||
instance=instance,
|
||||
|
|
@ -172,12 +173,12 @@ def make_log_m2m_changes(field_name):
|
|||
return
|
||||
LogEntry = get_logentry_model()
|
||||
|
||||
model_manager = _get_manager_from_settings(kwargs["model"])
|
||||
|
||||
if action == "post_clear":
|
||||
changed_queryset = kwargs["model"]._default_manager.all()
|
||||
changed_queryset = model_manager.all()
|
||||
else:
|
||||
changed_queryset = kwargs["model"]._default_manager.filter(
|
||||
pk__in=kwargs["pk_set"]
|
||||
)
|
||||
changed_queryset = model_manager.filter(pk__in=kwargs["pk_set"])
|
||||
|
||||
if action in ["post_add"]:
|
||||
LogEntry.objects.log_m2m_changes(
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import copy
|
||||
from collections import defaultdict
|
||||
from collections.abc import Collection, Iterable
|
||||
from typing import Any, Callable, Optional, Union
|
||||
from collections.abc import Callable, Collection, Iterable
|
||||
from typing import Any
|
||||
|
||||
from django.apps import apps
|
||||
from django.db.models import ManyToManyField, Model
|
||||
|
|
@ -38,7 +38,7 @@ class AuditlogModelRegistry:
|
|||
delete: bool = True,
|
||||
access: bool = True,
|
||||
m2m: bool = True,
|
||||
custom: Optional[dict[ModelSignal, Callable]] = None,
|
||||
custom: dict[ModelSignal, Callable] | None = None,
|
||||
):
|
||||
from auditlog.receivers import log_access, log_create, log_delete, log_update
|
||||
|
||||
|
|
@ -62,14 +62,14 @@ class AuditlogModelRegistry:
|
|||
def register(
|
||||
self,
|
||||
model: ModelBase = None,
|
||||
include_fields: Optional[list[str]] = None,
|
||||
exclude_fields: Optional[list[str]] = None,
|
||||
mapping_fields: Optional[dict[str, str]] = None,
|
||||
mask_fields: Optional[list[str]] = None,
|
||||
mask_callable: Optional[str] = None,
|
||||
m2m_fields: Optional[Collection[str]] = None,
|
||||
include_fields: list[str] | None = None,
|
||||
exclude_fields: list[str] | None = None,
|
||||
mapping_fields: dict[str, str] | None = None,
|
||||
mask_fields: list[str] | None = None,
|
||||
mask_callable: str | None = None,
|
||||
m2m_fields: Collection[str] | None = None,
|
||||
serialize_data: bool = False,
|
||||
serialize_kwargs: Optional[dict[str, Any]] = None,
|
||||
serialize_kwargs: dict[str, Any] | None = None,
|
||||
serialize_auditlog_fields_only: bool = False,
|
||||
):
|
||||
"""
|
||||
|
|
@ -259,7 +259,7 @@ class AuditlogModelRegistry:
|
|||
]
|
||||
return exclude_models
|
||||
|
||||
def _register_models(self, models: Iterable[Union[str, dict[str, Any]]]) -> None:
|
||||
def _register_models(self, models: Iterable[str | dict[str, Any]]) -> None:
|
||||
models = copy.deepcopy(models)
|
||||
for model in models:
|
||||
if isinstance(model, str):
|
||||
|
|
|
|||
0
auditlog_tests/auditlog
Normal file
0
auditlog_tests/auditlog
Normal file
|
|
@ -430,6 +430,40 @@ 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)
|
||||
|
|
@ -459,6 +493,8 @@ 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)
|
||||
|
|
|
|||
|
|
@ -48,6 +48,8 @@ from test_app.models import (
|
|||
ProxyModel,
|
||||
RelatedModel,
|
||||
ReusableThroughRelatedModel,
|
||||
SecretM2MModel,
|
||||
SecretRelatedModel,
|
||||
SerializeNaturalKeyRelatedModel,
|
||||
SerializeOnlySomeOfThisModel,
|
||||
SerializePrimaryKeyRelatedModel,
|
||||
|
|
@ -1369,7 +1371,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()), 34)
|
||||
self.assertEqual(len(self.test_auditlog.get_models()), 36)
|
||||
|
||||
def test_register_models_register_model_with_attrs(self):
|
||||
self.test_auditlog._register_models(
|
||||
|
|
@ -2945,6 +2947,136 @@ 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)],
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class TestMaskStr(TestCase):
|
||||
"""Test the mask_str function that masks sensitive data."""
|
||||
|
||||
|
|
|
|||
|
|
@ -11,10 +11,10 @@ The repository can be found at https://github.com/jazzband/django-auditlog/.
|
|||
|
||||
**Requirements**
|
||||
|
||||
- Python 3.9 or higher
|
||||
- Python 3.10 or higher
|
||||
- Django 4.2, 5.0, 5.1, and 5.2
|
||||
|
||||
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
|
||||
Auditlog is currently tested with Python 3.10+ and Django 4.2, 5.0, 5.1, and 5.2. The latest test report can be found
|
||||
at https://github.com/jazzband/django-auditlog/actions.
|
||||
|
||||
Adding Auditlog to your Django application
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -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,6 +377,73 @@ 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``
|
||||
|
||||
Actors
|
||||
------
|
||||
|
||||
|
|
|
|||
3
setup.py
3
setup.py
|
|
@ -29,12 +29,11 @@ setup(
|
|||
description="Audit log app for Django",
|
||||
long_description=long_description,
|
||||
long_description_content_type="text/markdown",
|
||||
python_requires=">=3.9",
|
||||
python_requires=">=3.10",
|
||||
install_requires=["Django>=4.2", "python-dateutil>=2.7.0"],
|
||||
zip_safe=False,
|
||||
classifiers=[
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
|
|
|
|||
17
tox.ini
17
tox.ini
|
|
@ -1,13 +1,14 @@
|
|||
[tox]
|
||||
envlist =
|
||||
{py39,py310,py311}-{customlogmodel,defaultlogmodel}-django42
|
||||
{py310,py311}-{customlogmodel,defaultlogmodel}-django42
|
||||
{py310,py311,py312}-{customlogmodel,defaultlogmodel}-django50
|
||||
{py310,py311,py312,py313}-{customlogmodel,defaultlogmodel}-django51
|
||||
{py310,py311,py312,py313}-{customlogmodel,defaultlogmodel}-django52
|
||||
{py312,py313}-{customlogmodel,defaultlogmodel}-djangomain
|
||||
py39-docs
|
||||
py39-lint
|
||||
py39-checkmigrations
|
||||
py310-docs
|
||||
py310-lint
|
||||
py310-checkmigrations
|
||||
|
||||
|
||||
[testenv]
|
||||
setenv =
|
||||
|
|
@ -43,19 +44,18 @@ basepython =
|
|||
py312: python3.12
|
||||
py311: python3.11
|
||||
py310: python3.10
|
||||
py39: python3.9
|
||||
|
||||
[testenv:py39-docs]
|
||||
[testenv:py310-docs]
|
||||
changedir = docs/source
|
||||
deps = -rdocs/requirements.txt
|
||||
commands = sphinx-build -W -b html -d {envtmpdir}/doctrees . {envtmpdir}/html
|
||||
|
||||
[testenv:py39-lint]
|
||||
[testenv:py310-lint]
|
||||
deps = pre-commit
|
||||
commands =
|
||||
pre-commit run --all-files
|
||||
|
||||
[testenv:py39-checkmigrations]
|
||||
[testenv:py310-checkmigrations]
|
||||
description = Check for missing migrations
|
||||
changedir = auditlog_tests
|
||||
deps =
|
||||
|
|
@ -74,7 +74,6 @@ commands =
|
|||
|
||||
[gh-actions]
|
||||
python =
|
||||
3.9: py39
|
||||
3.10: py310
|
||||
3.11: py311
|
||||
3.12: py312
|
||||
|
|
|
|||
Loading…
Reference in a new issue