mirror of
https://github.com/jazzband/django-auditlog.git
synced 2026-03-16 22:20:26 +00:00
Added support for Correlation ID
Co-authored-by: Hasan Ramezani <hasan.r67@gmail.com>
This commit is contained in:
parent
63c88829e0
commit
bc6d393390
13 changed files with 251 additions and 10 deletions
|
|
@ -2,6 +2,10 @@
|
|||
|
||||
## Next Release
|
||||
|
||||
#### Improvements
|
||||
|
||||
- feat: Added support for Correlation ID
|
||||
|
||||
#### Fixes
|
||||
|
||||
- fix: Make sure `LogEntry.changes_dict()` returns an empty dict instead of `None` when `json.loads()` returns `None`. ([#472](https://github.com/jazzband/django-auditlog/pull/472))
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ from django.contrib import admin
|
|||
from django.contrib.auth import get_user_model
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from auditlog.filters import ResourceTypeFilter
|
||||
from auditlog.filters import CIDFilter, ResourceTypeFilter
|
||||
from auditlog.mixins import LogEntryAdminMixin
|
||||
from auditlog.models import LogEntry
|
||||
|
||||
|
|
@ -10,7 +10,14 @@ from auditlog.models import LogEntry
|
|||
@admin.register(LogEntry)
|
||||
class LogEntryAdmin(admin.ModelAdmin, LogEntryAdminMixin):
|
||||
list_select_related = ["content_type", "actor"]
|
||||
list_display = ["created", "resource_url", "action", "msg_short", "user_url"]
|
||||
list_display = [
|
||||
"created",
|
||||
"resource_url",
|
||||
"action",
|
||||
"msg_short",
|
||||
"user_url",
|
||||
"cid_url",
|
||||
]
|
||||
search_fields = [
|
||||
"timestamp",
|
||||
"object_repr",
|
||||
|
|
@ -19,10 +26,10 @@ class LogEntryAdmin(admin.ModelAdmin, LogEntryAdminMixin):
|
|||
"actor__last_name",
|
||||
f"actor__{get_user_model().USERNAME_FIELD}",
|
||||
]
|
||||
list_filter = ["action", ResourceTypeFilter]
|
||||
list_filter = ["action", ResourceTypeFilter, CIDFilter]
|
||||
readonly_fields = ["created", "resource_url", "action", "user_url", "msg"]
|
||||
fieldsets = [
|
||||
(None, {"fields": ["created", "user_url", "resource_url"]}),
|
||||
(None, {"fields": ["created", "user_url", "resource_url", "cid"]}),
|
||||
(_("Changes"), {"fields": ["action", "msg"]}),
|
||||
]
|
||||
|
||||
|
|
@ -34,3 +41,7 @@ class LogEntryAdmin(admin.ModelAdmin, LogEntryAdminMixin):
|
|||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
return False
|
||||
|
||||
def get_queryset(self, request):
|
||||
self.request = request
|
||||
return super().get_queryset(request=request)
|
||||
|
|
|
|||
66
auditlog/cid.py
Normal file
66
auditlog/cid.py
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
from contextvars import ContextVar
|
||||
from typing import Optional
|
||||
|
||||
from django.conf import settings
|
||||
from django.http import HttpRequest
|
||||
from django.utils.module_loading import import_string
|
||||
|
||||
correlation_id = ContextVar("auditlog_correlation_id", default=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`.
|
||||
|
||||
Note: we look for the header in `request.headers` and `request.META`.
|
||||
|
||||
:param request: The request to get the cid from.
|
||||
:return: None
|
||||
"""
|
||||
cid = None
|
||||
header = settings.AUDITLOG_CID_HEADER
|
||||
|
||||
if header and request:
|
||||
if header in request.headers:
|
||||
cid = request.headers.get(header)
|
||||
elif header in request.META:
|
||||
cid = request.META.get(header)
|
||||
|
||||
# Ideally, this line should be nested inside the if statement.
|
||||
# However, because the tests do not run requests in multiple threads,
|
||||
# we have to always set the value of the cid,
|
||||
# even if the request does not have the header present,
|
||||
# in which case it will be set to None
|
||||
correlation_id.set(cid)
|
||||
|
||||
|
||||
def _get_cid() -> Optional[str]:
|
||||
return correlation_id.get()
|
||||
|
||||
|
||||
def get_cid() -> Optional[str]:
|
||||
"""
|
||||
Calls the cid getter function based on `settings.AUDITLOG_CID_GETTER`
|
||||
|
||||
If the setting value is:
|
||||
|
||||
* None: then it calls the default getter (which retrieves the value set in `set_cid`)
|
||||
* callable: then it calls the function
|
||||
* type(str): then it imports the function and then call it
|
||||
|
||||
The result is then returned to the caller.
|
||||
|
||||
If your custom getter does not depend on `set_header()`,
|
||||
then we recommend setting `settings.AUDITLOG_CID_GETTER` to `None`.
|
||||
|
||||
:return: The correlation ID
|
||||
"""
|
||||
method = settings.AUDITLOG_CID_GETTER
|
||||
if not method:
|
||||
return _get_cid()
|
||||
|
||||
if callable(method):
|
||||
return method()
|
||||
|
||||
return import_string(method)()
|
||||
|
|
@ -20,3 +20,10 @@ settings.AUDITLOG_INCLUDE_TRACKING_MODELS = getattr(
|
|||
settings.AUDITLOG_DISABLE_ON_RAW_SAVE = getattr(
|
||||
settings, "AUDITLOG_DISABLE_ON_RAW_SAVE", False
|
||||
)
|
||||
|
||||
# CID
|
||||
|
||||
settings.AUDITLOG_CID_HEADER = getattr(
|
||||
settings, "AUDITLOG_CID_HEADER", "x-correlation-id"
|
||||
)
|
||||
settings.AUDITLOG_CID_GETTER = getattr(settings, "AUDITLOG_CID_GETTER", None)
|
||||
|
|
|
|||
|
|
@ -15,3 +15,19 @@ class ResourceTypeFilter(SimpleListFilter):
|
|||
if self.value() is None:
|
||||
return queryset
|
||||
return queryset.filter(content_type_id=self.value())
|
||||
|
||||
|
||||
class CIDFilter(SimpleListFilter):
|
||||
title = _("Correlation ID")
|
||||
parameter_name = "cid"
|
||||
|
||||
def lookups(self, request, model_admin):
|
||||
return []
|
||||
|
||||
def has_output(self):
|
||||
return True
|
||||
|
||||
def queryset(self, request, queryset):
|
||||
if self.value() is None:
|
||||
return queryset
|
||||
return queryset.filter(cid=self.value())
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import contextlib
|
||||
|
||||
from auditlog.cid import set_cid
|
||||
from auditlog.context import set_actor
|
||||
|
||||
|
||||
|
|
@ -32,6 +33,8 @@ class AuditlogMiddleware:
|
|||
def __call__(self, request):
|
||||
remote_addr = self._get_remote_addr(request)
|
||||
|
||||
set_cid(request)
|
||||
|
||||
if hasattr(request, "user") and request.user.is_authenticated:
|
||||
context = set_actor(actor=request.user, remote_addr=remote_addr)
|
||||
else:
|
||||
|
|
|
|||
24
auditlog/migrations/0014_logentry_cid.py
Normal file
24
auditlog/migrations/0014_logentry_cid.py
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# Generated by Django 4.1.4 on 2022-12-18 13:24
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("auditlog", "0013_alter_logentry_timestamp"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="logentry",
|
||||
name="cid",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
db_index=True,
|
||||
max_length=255,
|
||||
null=True,
|
||||
verbose_name="Correlation ID",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -3,6 +3,7 @@ from django.conf import settings
|
|||
from django.contrib import admin
|
||||
from django.core.exceptions import FieldDoesNotExist
|
||||
from django.forms.utils import pretty_name
|
||||
from django.http import HttpRequest
|
||||
from django.urls.exceptions import NoReverseMatch
|
||||
from django.utils.html import format_html, format_html_join
|
||||
from django.utils.safestring import mark_safe
|
||||
|
|
@ -17,6 +18,9 @@ MAX = 75
|
|||
|
||||
|
||||
class LogEntryAdminMixin:
|
||||
request: HttpRequest
|
||||
CID_TITLE = _("Click to filter by records with this correlation id")
|
||||
|
||||
@admin.display(description=_("Created"))
|
||||
def created(self, obj):
|
||||
return localtime(obj.timestamp)
|
||||
|
|
@ -112,6 +116,15 @@ class LogEntryAdminMixin:
|
|||
|
||||
return mark_safe("".join(msg))
|
||||
|
||||
@admin.display(description="Correlation ID")
|
||||
def cid_url(self, obj):
|
||||
cid = obj.cid
|
||||
if cid:
|
||||
url = self._add_query_parameter("cid", cid)
|
||||
return format_html(
|
||||
'<a href="{}" title="{}">{}</a>', url, self.CID_TITLE, cid
|
||||
)
|
||||
|
||||
def _format_header(self, *labels):
|
||||
return format_html(
|
||||
"".join(["<tr>", "<th>{}</th>" * len(labels), "</tr>"]), *labels
|
||||
|
|
@ -138,6 +151,12 @@ class LogEntryAdminMixin:
|
|||
except FieldDoesNotExist:
|
||||
return pretty_name(field_name)
|
||||
|
||||
def _add_query_parameter(self, key: str, value: str):
|
||||
full_path = self.request.get_full_path()
|
||||
delimiter = "&" if "?" in full_path else "?"
|
||||
|
||||
return f"{full_path}{delimiter}{key}={value}"
|
||||
|
||||
|
||||
class LogAccessMixin:
|
||||
def render_to_response(self, context, **response_kwargs):
|
||||
|
|
|
|||
|
|
@ -37,6 +37,8 @@ class LogEntryManager(models.Manager):
|
|||
:return: The new log entry or `None` if there were no changes.
|
||||
:rtype: LogEntry
|
||||
"""
|
||||
from auditlog.cid import get_cid
|
||||
|
||||
changes = kwargs.get("changes", None)
|
||||
pk = self._get_pk_value(instance)
|
||||
|
||||
|
|
@ -76,6 +78,9 @@ class LogEntryManager(models.Manager):
|
|||
content_type=kwargs.get("content_type"),
|
||||
object_pk=kwargs.get("object_pk", ""),
|
||||
).delete()
|
||||
|
||||
# set correlation id
|
||||
kwargs.setdefault("cid", get_cid())
|
||||
return self.create(**kwargs)
|
||||
return None
|
||||
|
||||
|
|
@ -96,6 +101,7 @@ class LogEntryManager(models.Manager):
|
|||
:return: The new log entry or `None` if there were no changes.
|
||||
:rtype: LogEntry
|
||||
"""
|
||||
from auditlog.cid import get_cid
|
||||
|
||||
pk = self._get_pk_value(instance)
|
||||
if changed_queryset is not None:
|
||||
|
|
@ -123,6 +129,7 @@ class LogEntryManager(models.Manager):
|
|||
}
|
||||
}
|
||||
)
|
||||
kwargs.setdefault("cid", get_cid())
|
||||
return self.create(**kwargs)
|
||||
|
||||
return None
|
||||
|
|
@ -352,6 +359,9 @@ class LogEntry(models.Model):
|
|||
related_name="+",
|
||||
verbose_name=_("actor"),
|
||||
)
|
||||
cid = models.CharField(
|
||||
max_length=255, db_index=True, blank=True, verbose_name=_("Correlation ID")
|
||||
)
|
||||
remote_addr = models.GenericIPAddressField(
|
||||
blank=True, null=True, verbose_name=_("remote address")
|
||||
)
|
||||
|
|
|
|||
2
auditlog_tests/fixtures/custom_get_cid.py
Normal file
2
auditlog_tests/fixtures/custom_get_cid.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
def get_cid():
|
||||
return "my custom get_cid"
|
||||
|
|
@ -21,11 +21,13 @@ from django.utils import dateformat, formats
|
|||
from django.utils import timezone as django_timezone
|
||||
|
||||
from auditlog.admin import LogEntryAdmin
|
||||
from auditlog.cid import get_cid
|
||||
from auditlog.context import disable_auditlog, set_actor
|
||||
from auditlog.diff import model_instance_diff
|
||||
from auditlog.middleware import AuditlogMiddleware
|
||||
from auditlog.models import LogEntry
|
||||
from auditlog.registry import AuditlogModelRegistry, AuditLogRegistrationError, auditlog
|
||||
from auditlog_tests.fixtures.custom_get_cid import get_cid as custom_get_cid
|
||||
from auditlog_tests.models import (
|
||||
AdditionalDataIncludedModel,
|
||||
AltPrimaryKeyModel,
|
||||
|
|
@ -481,6 +483,38 @@ class MiddlewareTest(TestCase):
|
|||
self.middleware._get_remote_addr(request), expected_remote_addr
|
||||
)
|
||||
|
||||
def test_cid(self):
|
||||
header = str(settings.AUDITLOG_CID_HEADER).lstrip("HTTP_").replace("_", "-")
|
||||
header_meta = "HTTP_" + header.upper().replace("-", "_")
|
||||
cid = "random_CID"
|
||||
|
||||
_settings = [
|
||||
# these tuples test reading the cid from the header defined in the settings
|
||||
({"AUDITLOG_CID_HEADER": header}, cid), # x-correlation-id
|
||||
({"AUDITLOG_CID_HEADER": header_meta}, cid), # HTTP_X_CORRELATION_ID
|
||||
({"AUDITLOG_CID_HEADER": None}, None),
|
||||
# these two tuples test using a custom getter.
|
||||
# Here, we don't necessarily care about the cid that was set in set_cid
|
||||
(
|
||||
{
|
||||
"AUDITLOG_CID_GETTER": "auditlog_tests.fixtures.custom_get_cid.get_cid"
|
||||
},
|
||||
custom_get_cid(),
|
||||
),
|
||||
({"AUDITLOG_CID_GETTER": custom_get_cid}, custom_get_cid()),
|
||||
]
|
||||
for setting, expected_result in _settings:
|
||||
with self.subTest():
|
||||
with self.settings(**setting):
|
||||
request = self.factory.get("/", **{header_meta: cid})
|
||||
self.middleware(request)
|
||||
|
||||
obj = SimpleModel.objects.create(text="I am not difficult.")
|
||||
history = obj.history.get(action=LogEntry.Action.CREATE)
|
||||
|
||||
self.assertEqual(history.cid, expected_result)
|
||||
self.assertEqual(get_cid(), expected_result)
|
||||
|
||||
|
||||
class SimpleIncludeModelTest(TestCase):
|
||||
"""Log only changes in include_fields"""
|
||||
|
|
@ -593,7 +627,7 @@ class SimpleMappingModelTest(TestCase):
|
|||
)
|
||||
|
||||
|
||||
class SimpeMaskedFieldsModelTest(TestCase):
|
||||
class SimpleMaskedFieldsModelTest(TestCase):
|
||||
"""Log masked changes for fields in mask_fields"""
|
||||
|
||||
def test_register_mask_fields(self):
|
||||
|
|
@ -1214,7 +1248,7 @@ class ChoicesFieldModelTest(TestCase):
|
|||
assert "related_models" in history.changes_display_dict
|
||||
|
||||
|
||||
class CharfieldTextfieldModelTest(TestCase):
|
||||
class CharFieldTextFieldModelTest(TestCase):
|
||||
def setUp(self):
|
||||
self.PLACEHOLDER_LONGCHAR = "s" * 255
|
||||
self.PLACEHOLDER_LONGTEXTFIELD = "s" * 1000
|
||||
|
|
@ -1332,6 +1366,21 @@ class AdminPanelTest(TestCase):
|
|||
created = self.admin.created(log_entry)
|
||||
self.assertEqual(created.strftime("%Y-%m-%d %H:%M:%S"), timestamp)
|
||||
|
||||
def test_cid(self):
|
||||
self.client.force_login(self.user)
|
||||
expected_response = (
|
||||
'<a href="/admin/auditlog/logentry/?cid=123" '
|
||||
'title="Click to filter by records with this correlation id">123</a>'
|
||||
)
|
||||
|
||||
log_entry = self.obj.history.latest()
|
||||
log_entry.cid = "123"
|
||||
log_entry.save()
|
||||
|
||||
res = self.client.get("/admin/auditlog/logentry/")
|
||||
self.assertEqual(res.status_code, 200)
|
||||
self.assertIn(expected_response, res.rendered_content)
|
||||
|
||||
|
||||
class DiffMsgTest(TestCase):
|
||||
def setUp(self):
|
||||
|
|
|
|||
|
|
@ -19,6 +19,12 @@ Middleware
|
|||
.. automodule:: auditlog.middleware
|
||||
:members: AuditlogMiddleware
|
||||
|
||||
Correlation ID
|
||||
--------------
|
||||
|
||||
.. automodule:: auditlog.cid
|
||||
:members: get_cid, set_cid
|
||||
|
||||
Signal receivers
|
||||
----------------
|
||||
|
||||
|
|
|
|||
|
|
@ -147,8 +147,8 @@ Objects are serialized using the Django core serializer. Keyword arguments may b
|
|||
.. code-block:: python
|
||||
|
||||
auditlog.register(
|
||||
MyModel,
|
||||
serialize_data=True,
|
||||
MyModel,
|
||||
serialize_data=True,
|
||||
serialize_kwargs={"fields": ["foo", "bar", "biz", "baz"]}
|
||||
)
|
||||
|
||||
|
|
@ -163,8 +163,18 @@ Note that all fields on the object will be serialized unless restricted with one
|
|||
serialize_auditlog_fields_only=True
|
||||
)
|
||||
|
||||
Field masking is supported in object serialization. Any value belonging to a field whose name is found in the ``mask_fields`` list will be masked in the serialized object data. Masked values are obfuscated with asterisks in the same way as they are in the ``LogEntry.changes`` field.
|
||||
Field masking is supported in object serialization. Any value belonging to a field whose name is found in the ``mask_fields`` list will be masked in the serialized object data. Masked values are obfuscated with asterisks in the same way as they are in the ``LogEntry.changes`` field.
|
||||
|
||||
Correlation ID
|
||||
--------------
|
||||
|
||||
You can store a correlation ID (cid) in the log entries by:
|
||||
|
||||
1. Reading from a request header (specified by `AUDITLOG_CID_HEADER`)
|
||||
2. Using a custom cid getter (specified by `AUDITLOG_CID_GETTER`)
|
||||
|
||||
Using the custom getter is helpful for integrating with a third-party cid package
|
||||
such as `django-cid <https://pypi.org/project/django-cid/>`_.
|
||||
|
||||
Settings
|
||||
--------
|
||||
|
|
@ -214,7 +224,7 @@ It must be a list or tuple. Each item in this setting can be a:
|
|||
},
|
||||
"mask_fields": ["field5", "field6"],
|
||||
"m2m_fields": ["field7", "field8"],
|
||||
"serialize_data": True,
|
||||
"serialize_data": True,
|
||||
"serialize_auditlog_fields_only": False,
|
||||
"serialize_kwargs": {"fields": ["foo", "bar", "biz", "baz"]},
|
||||
},
|
||||
|
|
@ -234,6 +244,20 @@ Disables logging during raw save. (I.e. for instance using loaddata)
|
|||
|
||||
.. versionadded:: 2.2.0
|
||||
|
||||
**AUDITLOG_CID_HEADER**
|
||||
|
||||
The request header containing the Correlation ID value to use in all log entries created as a result of the request.
|
||||
The value can of in the format `HTTP_MY_HEADER` or `my-header`.
|
||||
|
||||
.. versionadded:: 3.0.0
|
||||
|
||||
**AUDITLOG_CID_GETTER**
|
||||
|
||||
The function to use to retrieve the Correlation ID. The value can be a callable or a string import path.
|
||||
|
||||
If the value is `None`, the default getter will be used.
|
||||
|
||||
.. versionadded:: 3.0.0
|
||||
|
||||
Actors
|
||||
------
|
||||
|
|
|
|||
Loading…
Reference in a new issue