mirror of
https://github.com/jazzband/django-auditlog.git
synced 2026-03-16 22:20:26 +00:00
Add CustomLogEntry model support and update tests: (#764)
* Add CustomLogEntry model support and update tests: - Added support for CustomLogEntry data model to extend django-auditlog capabilities - Updated existing test cases to align with new model structure and data handling logic - Added new test cases to validate CustomLogEntry behavior, model registration, and signal handling - Ensured backward compatibility with existing LogEntry model where applicable * Update auditlog/__init__.py Co-authored-by: Youngkwang Yang <me@youngkwang.dev> * run only one custom model test matrix (#761) --------- Co-authored-by: Youngkwang Yang <me@youngkwang.dev>
This commit is contained in:
parent
0e58a9d2d5
commit
7d13fd4ba8
28 changed files with 441 additions and 63 deletions
|
|
@ -1,3 +1,25 @@
|
|||
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():
|
||||
try:
|
||||
return django_apps.get_model(
|
||||
settings.AUDITLOG_LOGENTRY_MODEL, 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"
|
||||
% settings.AUDITLOG_LOGENTRY_MODEL
|
||||
)
|
||||
|
|
|
|||
|
|
@ -4,9 +4,11 @@ from django.contrib import admin
|
|||
from django.contrib.auth import get_user_model
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from auditlog import get_logentry_model
|
||||
from auditlog.filters import CIDFilter, ResourceTypeFilter
|
||||
from auditlog.mixins import LogEntryAdminMixin
|
||||
from auditlog.models import LogEntry
|
||||
|
||||
LogEntry = get_logentry_model()
|
||||
|
||||
|
||||
@admin.register(LogEntry)
|
||||
|
|
|
|||
|
|
@ -63,6 +63,10 @@ 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
|
||||
|
|
|
|||
|
|
@ -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.models import LogEntry
|
||||
from auditlog import get_logentry_model
|
||||
|
||||
auditlog_value = ContextVar("auditlog_value")
|
||||
auditlog_disabled = ContextVar("auditlog_disabled", default=False)
|
||||
|
|
@ -14,23 +14,33 @@ 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 = {
|
||||
"signal_duid": ("set_actor", time.time()),
|
||||
"actor": actor,
|
||||
"remote_addr": remote_addr,
|
||||
"remote_port": remote_port,
|
||||
}
|
||||
return call_context_manager(context_data)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def set_extra_data(context_data):
|
||||
return call_context_manager(context_data)
|
||||
|
||||
|
||||
def call_context_manager(context_data):
|
||||
"""Connect a signal receiver with current user attached."""
|
||||
LogEntry = get_logentry_model()
|
||||
# Initialize thread local storage
|
||||
context_data["signal_duid"] = ("set_actor", time.time())
|
||||
auditlog_value.set(context_data)
|
||||
|
||||
# Connect signal for automatic logging
|
||||
set_actor = partial(
|
||||
_set_actor,
|
||||
user=actor,
|
||||
set_extra_data = partial(
|
||||
_set_extra_data,
|
||||
signal_duid=context_data["signal_duid"],
|
||||
)
|
||||
pre_save.connect(
|
||||
set_actor,
|
||||
set_extra_data,
|
||||
sender=LogEntry,
|
||||
dispatch_uid=context_data["signal_duid"],
|
||||
weak=False,
|
||||
|
|
@ -47,11 +57,26 @@ def set_actor(actor, remote_addr=None, remote_port=None):
|
|||
pre_save.disconnect(sender=LogEntry, dispatch_uid=auditlog["signal_duid"])
|
||||
|
||||
|
||||
def _set_actor(user, sender, instance, signal_duid, **kwargs):
|
||||
def _set_actor(auditlog, instance, sender):
|
||||
LogEntry = get_logentry_model()
|
||||
auth_user_model = get_user_model()
|
||||
if "actor" in auditlog:
|
||||
actor = auditlog.get("actor")
|
||||
if (
|
||||
sender == LogEntry
|
||||
and isinstance(actor, auth_user_model)
|
||||
and instance.actor is None
|
||||
):
|
||||
instance.actor = actor
|
||||
instance.actor_email = getattr(actor, "email", None)
|
||||
|
||||
|
||||
def _set_extra_data(sender, instance, signal_duid, **kwargs):
|
||||
"""Signal receiver with extra 'user' and 'signal_duid' kwargs.
|
||||
|
||||
This function becomes a valid signal receiver when it is curried with the actor and a dispatch id.
|
||||
"""
|
||||
LogEntry = get_logentry_model()
|
||||
try:
|
||||
auditlog = auditlog_value.get()
|
||||
except LookupError:
|
||||
|
|
@ -59,17 +84,15 @@ def _set_actor(user, sender, instance, signal_duid, **kwargs):
|
|||
else:
|
||||
if signal_duid != auditlog["signal_duid"]:
|
||||
return
|
||||
auth_user_model = get_user_model()
|
||||
if (
|
||||
sender == LogEntry
|
||||
and isinstance(user, auth_user_model)
|
||||
and instance.actor is None
|
||||
):
|
||||
instance.actor = user
|
||||
instance.actor_email = getattr(user, "email", None)
|
||||
|
||||
instance.remote_addr = auditlog["remote_addr"]
|
||||
instance.remote_port = auditlog["remote_port"]
|
||||
_set_actor(auditlog, instance, sender)
|
||||
|
||||
for key in auditlog:
|
||||
if key != "actor" and hasattr(LogEntry, key):
|
||||
if callable(auditlog[key]):
|
||||
setattr(instance, key, auditlog[key]())
|
||||
else:
|
||||
setattr(instance, key, auditlog[key])
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ 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):
|
||||
"""
|
||||
|
|
@ -21,7 +23,6 @@ def track_field(field):
|
|||
:return: Whether the given field should be tracked.
|
||||
:rtype: bool
|
||||
"""
|
||||
from auditlog.models import LogEntry
|
||||
|
||||
# Do not track many to many relations
|
||||
if field.many_to_many:
|
||||
|
|
@ -30,7 +31,7 @@ def track_field(field):
|
|||
# Do not track relations to LogEntry
|
||||
if (
|
||||
getattr(field, "remote_field", None) is not None
|
||||
and field.remote_field.model == LogEntry
|
||||
and field.remote_field.model == get_logentry_model()
|
||||
):
|
||||
return False
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,9 @@ import datetime
|
|||
from django.core.management.base import BaseCommand
|
||||
from django.db import connection
|
||||
|
||||
from auditlog.models import LogEntry
|
||||
from auditlog import get_logentry_model
|
||||
|
||||
LogEntry = get_logentry_model()
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@ from django.conf import settings
|
|||
from django.core.management import CommandError, CommandParser
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from auditlog.models import LogEntry
|
||||
from auditlog import get_logentry_model
|
||||
|
||||
LogEntry = get_logentry_model()
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
|
|
@ -125,8 +127,8 @@ class Command(BaseCommand):
|
|||
def postgres():
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
"""
|
||||
UPDATE auditlog_logentry
|
||||
f"""
|
||||
UPDATE {LogEntry._meta.db_table}
|
||||
SET changes="changes_text"::jsonb
|
||||
WHERE changes_text IS NOT NULL
|
||||
AND changes_text <> ''
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ from django.conf import settings
|
|||
from django.contrib.auth import get_user_model
|
||||
|
||||
from auditlog.cid import set_cid
|
||||
from auditlog.context import set_actor
|
||||
from auditlog.context import set_extra_data
|
||||
|
||||
|
||||
class AuditlogMiddleware:
|
||||
|
|
@ -54,12 +54,17 @@ class AuditlogMiddleware:
|
|||
return user
|
||||
return None
|
||||
|
||||
def __call__(self, request):
|
||||
remote_addr = self._get_remote_addr(request)
|
||||
remote_port = self._get_remote_port(request)
|
||||
user = self._get_actor(request)
|
||||
def get_extra_data(self, request):
|
||||
context_data = {}
|
||||
context_data["remote_addr"] = self._get_remote_addr(request)
|
||||
context_data["remote_port"] = self._get_remote_port(request)
|
||||
|
||||
context_data["actor"] = self._get_actor(request)
|
||||
|
||||
return context_data
|
||||
|
||||
def __call__(self, request):
|
||||
set_cid(request)
|
||||
|
||||
with set_actor(actor=user, remote_addr=remote_addr, remote_port=remote_port):
|
||||
with set_extra_data(context_data=self.get_extra_data(request)):
|
||||
return self.get_response(request)
|
||||
|
|
|
|||
|
|
@ -14,10 +14,12 @@ from django.utils.text import capfirst
|
|||
from django.utils.timezone import is_aware, localtime
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from auditlog.models import LogEntry
|
||||
from auditlog import get_logentry_model
|
||||
from auditlog.render import get_field_verbose_name, render_logentry_changes_html
|
||||
from auditlog.signals import accessed
|
||||
|
||||
LogEntry = get_logentry_model()
|
||||
|
||||
MAX = 75
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ from django.utils import timezone as django_timezone
|
|||
from django.utils.encoding import smart_str
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from auditlog import get_logentry_model
|
||||
from auditlog.diff import get_mask_function
|
||||
|
||||
DEFAULT_OBJECT_REPR = "<error forming object repr>"
|
||||
|
|
@ -304,7 +305,7 @@ class LogEntryManager(models.Manager):
|
|||
return data
|
||||
|
||||
|
||||
class LogEntry(models.Model):
|
||||
class AbstractLogEntry(models.Model):
|
||||
"""
|
||||
Represents an entry in the audit log. The content type is saved along with the textual and numeric
|
||||
(if available) primary key, as well as the textual representation of the object when it was saved.
|
||||
|
|
@ -393,6 +394,7 @@ class LogEntry(models.Model):
|
|||
objects = LogEntryManager()
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
get_latest_by = "timestamp"
|
||||
ordering = ["-timestamp"]
|
||||
verbose_name = _("log entry")
|
||||
|
|
@ -562,6 +564,11 @@ class LogEntry(models.Model):
|
|||
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
|
||||
|
|
@ -582,7 +589,7 @@ class AuditlogHistoryField(GenericRelation):
|
|||
"""
|
||||
|
||||
def __init__(self, pk_indexable=True, delete_related=False, **kwargs):
|
||||
kwargs["to"] = LogEntry
|
||||
kwargs["to"] = get_logentry_model()
|
||||
|
||||
if pk_indexable:
|
||||
kwargs["object_id_field"] = "object_id"
|
||||
|
|
|
|||
|
|
@ -2,9 +2,10 @@ from functools import wraps
|
|||
|
||||
from django.conf import settings
|
||||
|
||||
from auditlog import get_logentry_model
|
||||
from auditlog.context import auditlog_disabled
|
||||
from auditlog.diff import model_instance_diff
|
||||
from auditlog.models import LogEntry, _get_manager_from_settings
|
||||
from auditlog.models import _get_manager_from_settings
|
||||
from auditlog.signals import post_log, pre_log
|
||||
|
||||
|
||||
|
|
@ -38,7 +39,7 @@ def log_create(sender, instance, created, **kwargs):
|
|||
"""
|
||||
if created:
|
||||
_create_log_entry(
|
||||
action=LogEntry.Action.CREATE,
|
||||
action=get_logentry_model().Action.CREATE,
|
||||
instance=instance,
|
||||
sender=sender,
|
||||
diff_old=None,
|
||||
|
|
@ -58,7 +59,7 @@ def log_update(sender, instance, **kwargs):
|
|||
update_fields = kwargs.get("update_fields", None)
|
||||
old = _get_manager_from_settings(sender).filter(pk=instance.pk).first()
|
||||
_create_log_entry(
|
||||
action=LogEntry.Action.UPDATE,
|
||||
action=get_logentry_model().Action.UPDATE,
|
||||
instance=instance,
|
||||
sender=sender,
|
||||
diff_old=old,
|
||||
|
|
@ -77,7 +78,7 @@ def log_delete(sender, instance, **kwargs):
|
|||
"""
|
||||
if instance.pk is not None:
|
||||
_create_log_entry(
|
||||
action=LogEntry.Action.DELETE,
|
||||
action=get_logentry_model().Action.DELETE,
|
||||
instance=instance,
|
||||
sender=sender,
|
||||
diff_old=instance,
|
||||
|
|
@ -94,7 +95,7 @@ def log_access(sender, instance, **kwargs):
|
|||
"""
|
||||
if instance.pk is not None:
|
||||
_create_log_entry(
|
||||
action=LogEntry.Action.ACCESS,
|
||||
action=get_logentry_model().Action.ACCESS,
|
||||
instance=instance,
|
||||
sender=sender,
|
||||
diff_old=None,
|
||||
|
|
@ -122,6 +123,7 @@ 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
|
||||
|
|
@ -169,6 +171,7 @@ 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"])
|
||||
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ class AuditlogModelRegistry:
|
|||
A registry that keeps track of the models that use Auditlog to track changes.
|
||||
"""
|
||||
|
||||
DEFAULT_EXCLUDE_MODELS = ("auditlog.LogEntry", "admin.LogEntry")
|
||||
DEFAULT_EXCLUDE_MODELS = (settings.AUDITLOG_LOGENTRY_MODEL, "admin.LogEntry")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
|
|
|
|||
0
auditlog_tests/custom_logentry_app/__init__.py
Normal file
0
auditlog_tests/custom_logentry_app/__init__.py
Normal file
5
auditlog_tests/custom_logentry_app/apps.py
Normal file
5
auditlog_tests/custom_logentry_app/apps.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CustomLogEntryConfig(AppConfig):
|
||||
name = "custom_logentry_app"
|
||||
138
auditlog_tests/custom_logentry_app/migrations/0001_initial.py
Normal file
138
auditlog_tests/custom_logentry_app/migrations/0001_initial.py
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
# Generated by Django 4.2.25 on 2025-10-14 04:17
|
||||
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
("contenttypes", "0002_remove_content_type_name"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="CustomLogEntryModel",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"object_pk",
|
||||
models.CharField(
|
||||
db_index=True, max_length=255, verbose_name="object pk"
|
||||
),
|
||||
),
|
||||
(
|
||||
"object_id",
|
||||
models.BigIntegerField(
|
||||
blank=True, db_index=True, null=True, verbose_name="object id"
|
||||
),
|
||||
),
|
||||
("object_repr", models.TextField(verbose_name="object representation")),
|
||||
("serialized_data", models.JSONField(null=True)),
|
||||
(
|
||||
"action",
|
||||
models.PositiveSmallIntegerField(
|
||||
choices=[
|
||||
(0, "create"),
|
||||
(1, "update"),
|
||||
(2, "delete"),
|
||||
(3, "access"),
|
||||
],
|
||||
db_index=True,
|
||||
verbose_name="action",
|
||||
),
|
||||
),
|
||||
(
|
||||
"changes_text",
|
||||
models.TextField(blank=True, verbose_name="change message"),
|
||||
),
|
||||
("changes", models.JSONField(null=True, verbose_name="change message")),
|
||||
(
|
||||
"cid",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
db_index=True,
|
||||
max_length=255,
|
||||
null=True,
|
||||
verbose_name="Correlation ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"remote_addr",
|
||||
models.GenericIPAddressField(
|
||||
blank=True, null=True, verbose_name="remote address"
|
||||
),
|
||||
),
|
||||
(
|
||||
"remote_port",
|
||||
models.PositiveIntegerField(
|
||||
blank=True, null=True, verbose_name="remote port"
|
||||
),
|
||||
),
|
||||
(
|
||||
"timestamp",
|
||||
models.DateTimeField(
|
||||
db_index=True,
|
||||
default=django.utils.timezone.now,
|
||||
verbose_name="timestamp",
|
||||
),
|
||||
),
|
||||
(
|
||||
"additional_data",
|
||||
models.JSONField(
|
||||
blank=True, null=True, verbose_name="additional data"
|
||||
),
|
||||
),
|
||||
(
|
||||
"actor_email",
|
||||
models.CharField(
|
||||
blank=True,
|
||||
max_length=254,
|
||||
null=True,
|
||||
verbose_name="actor email",
|
||||
),
|
||||
),
|
||||
("role", models.CharField(blank=True, max_length=100, null=True)),
|
||||
(
|
||||
"actor",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="+",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="actor",
|
||||
),
|
||||
),
|
||||
(
|
||||
"content_type",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="+",
|
||||
to="contenttypes.contenttype",
|
||||
verbose_name="content type",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "log entry",
|
||||
"verbose_name_plural": "log entries",
|
||||
"ordering": ["-timestamp"],
|
||||
"get_latest_by": "timestamp",
|
||||
"abstract": False,
|
||||
},
|
||||
),
|
||||
]
|
||||
7
auditlog_tests/custom_logentry_app/models.py
Normal file
7
auditlog_tests/custom_logentry_app/models.py
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
from django.db import models
|
||||
|
||||
from auditlog.models import AbstractLogEntry
|
||||
|
||||
|
||||
class CustomLogEntryModel(AbstractLogEntry):
|
||||
role = models.CharField(max_length=100, null=True, blank=True)
|
||||
0
auditlog_tests/custom_logentry_app/urls.py
Normal file
0
auditlog_tests/custom_logentry_app/urls.py
Normal file
0
auditlog_tests/custom_logentry_app/views.py
Normal file
0
auditlog_tests/custom_logentry_app/views.py
Normal file
12
auditlog_tests/middleware.py
Normal file
12
auditlog_tests/middleware.py
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
from auditlog.middleware import AuditlogMiddleware
|
||||
|
||||
|
||||
class CustomAuditlogMiddleware(AuditlogMiddleware):
|
||||
"""
|
||||
Custom Middleware to couple the request's user role to log items.
|
||||
"""
|
||||
|
||||
def get_extra_data(self, request):
|
||||
context_data = super().get_extra_data(request)
|
||||
context_data["role"] = "Role 1"
|
||||
return context_data
|
||||
|
|
@ -121,6 +121,9 @@ 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,9 +1,11 @@
|
|||
from django.test import TestCase
|
||||
from test_app.models import SimpleModel
|
||||
|
||||
from auditlog.models import LogEntry
|
||||
from auditlog import get_logentry_model
|
||||
from auditlog.templatetags.auditlog_tags import render_logentry_changes_html
|
||||
|
||||
LogEntry = get_logentry_model()
|
||||
|
||||
|
||||
class RenderChangesTest(TestCase):
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ INSTALLED_APPS = [
|
|||
"django.contrib.admin",
|
||||
"django.contrib.staticfiles",
|
||||
"django.contrib.postgres",
|
||||
"custom_logentry_app",
|
||||
"auditlog",
|
||||
"test_app",
|
||||
]
|
||||
|
|
@ -28,9 +29,14 @@ 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": {
|
||||
|
|
@ -100,3 +106,5 @@ 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,7 +8,9 @@ from django.test import TestCase, override_settings
|
|||
from django.test.utils import skipIf
|
||||
from test_app.models import SimpleModel
|
||||
|
||||
from auditlog.models import LogEntry
|
||||
from auditlog import get_logentry_model
|
||||
|
||||
LogEntry = get_logentry_model()
|
||||
|
||||
|
||||
class TwoStepMigrationTest(TestCase):
|
||||
|
|
@ -119,7 +121,10 @@ class AuditlogMigrateJsonTest(TestCase):
|
|||
self.make_logentry()
|
||||
|
||||
# Act
|
||||
with patch("auditlog.models.LogEntry.objects.bulk_update") as bulk_update:
|
||||
LogEntry = get_logentry_model()
|
||||
path = f"{LogEntry.__module__}.{LogEntry.__name__}.objects.bulk_update"
|
||||
|
||||
with patch(path) as bulk_update:
|
||||
outbuf, errbuf = self.call_command("-b=1")
|
||||
call_count = bulk_update.call_count
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
from django.test import TestCase, override_settings
|
||||
from test_app.models import JSONModel, NullableFieldModel, RelatedModel, SimpleModel
|
||||
|
||||
from auditlog.models import LogEntry
|
||||
from auditlog import get_logentry_model
|
||||
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
|
||||
from unittest import mock, skipIf
|
||||
from unittest.mock import patch
|
||||
|
||||
import freezegun
|
||||
|
|
@ -17,6 +17,8 @@ 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
|
||||
|
|
@ -62,15 +64,18 @@ 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
|
||||
from auditlog.context import disable_auditlog, set_actor, set_extra_data
|
||||
from auditlog.diff import mask_str, model_instance_diff
|
||||
from auditlog.middleware import AuditlogMiddleware
|
||||
from auditlog.models import DEFAULT_OBJECT_REPR, LogEntry
|
||||
from auditlog.models import DEFAULT_OBJECT_REPR
|
||||
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):
|
||||
|
|
@ -260,7 +265,7 @@ class NoActorMixin:
|
|||
self.assertIsNone(log_entry.actor)
|
||||
|
||||
|
||||
class WithActorMixin:
|
||||
class WithActorMixinBase:
|
||||
sequence = itertools.count()
|
||||
|
||||
def setUp(self):
|
||||
|
|
@ -279,10 +284,6 @@ class WithActorMixin:
|
|||
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)
|
||||
|
|
@ -307,6 +308,12 @@ class WithActorMixin:
|
|||
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(
|
||||
|
|
@ -371,6 +378,10 @@ 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.
|
||||
|
|
@ -1741,21 +1752,24 @@ 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("/admin/auditlog/logentry/")
|
||||
res = self.client.get(f"/{self.admin_path_prefix}/")
|
||||
self.assertEqual(res.status_code, 200)
|
||||
res = self.client.get("/admin/auditlog/logentry/add/")
|
||||
res = self.client.get(f"/{self.admin_path_prefix}/add/")
|
||||
self.assertEqual(res.status_code, 403)
|
||||
res = self.client.get(f"/admin/auditlog/logentry/{log_pk}/", follow=True)
|
||||
res = self.client.get(f"/{self.admin_path_prefix}/{log_pk}/", follow=True)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
res = self.client.get(f"/admin/auditlog/logentry/{log_pk}/delete/")
|
||||
res = self.client.get(f"/{self.admin_path_prefix}/{log_pk}/delete/")
|
||||
self.assertEqual(res.status_code, 403)
|
||||
res = self.client.get(f"/admin/auditlog/logentry/{log_pk}/history/")
|
||||
res = self.client.get(f"/{self.admin_path_prefix}/{log_pk}/history/")
|
||||
self.assertEqual(res.status_code, 200)
|
||||
|
||||
def test_created_timezone(self):
|
||||
|
|
@ -1785,7 +1799,7 @@ class AdminPanelTest(TestCase):
|
|||
def test_cid(self):
|
||||
self.client.force_login(self.user)
|
||||
expected_response = (
|
||||
'<a href="/admin/auditlog/logentry/?cid=123" '
|
||||
f'<a href="/{self.admin_path_prefix}/?cid=123" '
|
||||
'title="Click to filter by records with this correlation id">123</a>'
|
||||
)
|
||||
|
||||
|
|
@ -1793,7 +1807,7 @@ class AdminPanelTest(TestCase):
|
|||
log_entry.cid = "123"
|
||||
log_entry.save()
|
||||
|
||||
res = self.client.get("/admin/auditlog/logentry/")
|
||||
res = self.client.get(f"/{self.admin_path_prefix}/")
|
||||
self.assertEqual(res.status_code, 200)
|
||||
self.assertIn(expected_response, res.rendered_content)
|
||||
|
||||
|
|
@ -1801,7 +1815,7 @@ class AdminPanelTest(TestCase):
|
|||
log = self.obj.history.latest()
|
||||
obj_pk = self.obj.pk
|
||||
delete_log_request = RequestFactory().post(
|
||||
f"/admin/auditlog/logentry/{log.pk}/delete/"
|
||||
f"/{self.admin_path_prefix}/{log.pk}/delete/"
|
||||
)
|
||||
delete_log_request.resolver_match = resolve(delete_log_request.path)
|
||||
delete_log_request.user = self.user
|
||||
|
|
@ -2798,7 +2812,7 @@ class SignalTests(TestCase):
|
|||
|
||||
self.assertSignals(LogEntry.Action.DELETE)
|
||||
|
||||
@patch("auditlog.receivers.LogEntry.objects")
|
||||
@patch.object(LogEntry, "objects")
|
||||
def test_signals_errors(self, log_entry_objects_mock):
|
||||
class CustomSignalError(BaseException):
|
||||
pass
|
||||
|
|
@ -3120,3 +3134,72 @@ 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()
|
||||
|
|
|
|||
|
|
@ -407,6 +407,43 @@ may see inaccurate data in log entries, recording changes to a seemingly
|
|||
|
||||
.. 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
tox.ini
3
tox.ini
|
|
@ -1,5 +1,6 @@
|
|||
[tox]
|
||||
envlist =
|
||||
{py312}-customlogmodel-django52
|
||||
{py310,py311}-django42
|
||||
{py310,py311,py312}-django50
|
||||
{py310,py311,py312,py313}-django51
|
||||
|
|
@ -9,9 +10,11 @@ envlist =
|
|||
py310-lint
|
||||
py310-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
|
||||
|
|
|
|||
Loading…
Reference in a new issue