diff --git a/CHANGELOG.md b/CHANGELOG.md
index 49b7eb9..ff21e6a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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))
diff --git a/auditlog/admin.py b/auditlog/admin.py
index a2e8511..454a0f1 100644
--- a/auditlog/admin.py
+++ b/auditlog/admin.py
@@ -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)
diff --git a/auditlog/cid.py b/auditlog/cid.py
new file mode 100644
index 0000000..e530869
--- /dev/null
+++ b/auditlog/cid.py
@@ -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)()
diff --git a/auditlog/conf.py b/auditlog/conf.py
index 4df8d87..a56a165 100644
--- a/auditlog/conf.py
+++ b/auditlog/conf.py
@@ -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)
diff --git a/auditlog/filters.py b/auditlog/filters.py
index d2323c9..18a4c86 100644
--- a/auditlog/filters.py
+++ b/auditlog/filters.py
@@ -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())
diff --git a/auditlog/middleware.py b/auditlog/middleware.py
index 00745bc..9d07ed7 100644
--- a/auditlog/middleware.py
+++ b/auditlog/middleware.py
@@ -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:
diff --git a/auditlog/migrations/0014_logentry_cid.py b/auditlog/migrations/0014_logentry_cid.py
new file mode 100644
index 0000000..57da8bc
--- /dev/null
+++ b/auditlog/migrations/0014_logentry_cid.py
@@ -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",
+ ),
+ ),
+ ]
diff --git a/auditlog/mixins.py b/auditlog/mixins.py
index 84c18d0..e7b6155 100644
--- a/auditlog/mixins.py
+++ b/auditlog/mixins.py
@@ -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(
+ '{}', url, self.CID_TITLE, cid
+ )
+
def _format_header(self, *labels):
return format_html(
"".join(["
", "| {} | " * len(labels), "
"]), *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):
diff --git a/auditlog/models.py b/auditlog/models.py
index 7925773..d5b04ff 100644
--- a/auditlog/models.py
+++ b/auditlog/models.py
@@ -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")
)
diff --git a/auditlog_tests/fixtures/custom_get_cid.py b/auditlog_tests/fixtures/custom_get_cid.py
new file mode 100644
index 0000000..a8e7045
--- /dev/null
+++ b/auditlog_tests/fixtures/custom_get_cid.py
@@ -0,0 +1,2 @@
+def get_cid():
+ return "my custom get_cid"
diff --git a/auditlog_tests/tests.py b/auditlog_tests/tests.py
index 1aa5369..290fdc6 100644
--- a/auditlog_tests/tests.py
+++ b/auditlog_tests/tests.py
@@ -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 = (
+ '123'
+ )
+
+ 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):
diff --git a/docs/source/internals.rst b/docs/source/internals.rst
index 4964a06..57163d2 100644
--- a/docs/source/internals.rst
+++ b/docs/source/internals.rst
@@ -19,6 +19,12 @@ Middleware
.. automodule:: auditlog.middleware
:members: AuditlogMiddleware
+Correlation ID
+--------------
+
+.. automodule:: auditlog.cid
+ :members: get_cid, set_cid
+
Signal receivers
----------------
diff --git a/docs/source/usage.rst b/docs/source/usage.rst
index 7ff6605..4961be2 100644
--- a/docs/source/usage.rst
+++ b/docs/source/usage.rst
@@ -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 `_.
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
------