diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 00000000..be006de9
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,13 @@
+# Keep GitHub Actions up to date with GitHub's Dependabot...
+# https://docs.github.com/en/code-security/dependabot/working-with-dependabot/keeping-your-actions-up-to-date-with-dependabot
+# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#package-ecosystem
+version: 2
+updates:
+ - package-ecosystem: github-actions
+ directory: /
+ groups:
+ github-actions:
+ patterns:
+ - "*" # Group all Actions updates into a single larger pull request
+ schedule:
+ interval: weekly
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 55a6b6b3..e0935e22 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -11,22 +11,22 @@ jobs:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v2
+ - uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Python
- uses: actions/setup-python@v2
+ uses: actions/setup-python@v5
with:
- python-version: 3.8
+ python-version: '3.9'
- name: Get pip cache dir
id: pip-cache
run: |
- echo "::set-output name=dir::$(pip cache dir)"
+ echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT
- name: Cache
- uses: actions/cache@v2
+ uses: actions/cache@v4
with:
path: ${{ steps.pip-cache.outputs.dir }}
key: release-${{ hashFiles('**/setup.py') }}
@@ -50,4 +50,4 @@ jobs:
with:
user: jazzband
password: ${{ secrets.JAZZBAND_RELEASE_KEY }}
- repository_url: https://jazzband.co/projects/django-auditlog/upload
+ repository-url: https://jazzband.co/projects/django-auditlog/upload
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 1eae44aa..28f2888b 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -9,38 +9,38 @@ jobs:
fail-fast: false
max-parallel: 5
matrix:
- python-version: ['3.7', '3.8', '3.9', '3.10']
+ python-version: ['3.9', '3.10', '3.11', '3.12', '3.13']
services:
postgres:
- image: postgres:12
+ image: postgres:15
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: postgres
ports:
- - 5432/tcp
+ - 5432/tcp
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
- --health-retries 5
+ --health-retries 10
steps:
- - uses: actions/checkout@v2
+ - uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
- uses: actions/setup-python@v2
+ uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Get pip cache dir
id: pip-cache
run: |
- echo "::set-output name=dir::$(pip cache dir)"
+ echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT
- name: Cache
- uses: actions/cache@v2
+ uses: actions/cache@v4
with:
path: ${{ steps.pip-cache.outputs.dir }}
key:
@@ -64,6 +64,6 @@ jobs:
TEST_DB_PORT: ${{ job.services.postgres.ports[5432] }}
- name: Upload coverage
- uses: codecov/codecov-action@v1
+ uses: codecov/codecov-action@v4
with:
name: Python ${{ matrix.python-version }}
diff --git a/.gitignore b/.gitignore
index 155da2b3..96288a16 100644
--- a/.gitignore
+++ b/.gitignore
@@ -80,3 +80,4 @@ venv.bak/
### JetBrains
.idea/
+.vscode/
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index f4078be2..fbf18742 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,24 +1,29 @@
---
repos:
- repo: https://github.com/psf/black
- rev: 22.3.0
+ rev: 24.10.0
hooks:
- id: black
- language_version: python3.8
+ language_version: python3.9
args:
- "--target-version"
- - "py37"
+ - "py39"
- repo: https://github.com/PyCQA/flake8
- rev: "4.0.1"
+ rev: "7.3.0"
hooks:
- id: flake8
args: ["--max-line-length", "110"]
- repo: https://github.com/PyCQA/isort
- rev: 5.10.1
+ rev: 5.13.2
hooks:
- id: isort
- repo: https://github.com/asottile/pyupgrade
- rev: v2.34.0
+ rev: v3.20.0
hooks:
- id: pyupgrade
- args: [--py37-plus]
\ No newline at end of file
+ args: [--py39-plus]
+ - repo: https://github.com/adamchainz/django-upgrade
+ rev: 1.25.0
+ hooks:
+ - id: django-upgrade
+ args: [--target-version, "4.2"]
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 50f2b4be..bc273968 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,117 @@
# Changes
+## Next Release
+
+#### Improvements
+
+- Add Python 3.13 support. ([#671](https://github.com/jazzband/django-auditlog/pull/671))
+- feat: Added `LogEntry.remote_port` field. ([#671](https://github.com/jazzband/django-auditlog/pull/671))
+- Drop Python 3.8 support. ([#678](https://github.com/jazzband/django-auditlog/pull/678))
+- Confirm Django 5.1 support and drop Django 3.2 support. ([#677](https://github.com/jazzband/django-auditlog/pull/677))
+
+#### Fixes
+
+- Fixed problem when setting `django.db.models.functions.Now()` in `DateTimeField` ([#635](https://github.com/jazzband/django-auditlog/pull/635))
+
+## 3.0.0 (2024-04-12)
+
+#### Fixes
+
+- Fixed manuall logging when model is not registered ([#627](https://github.com/jazzband/django-auditlog/pull/627))
+
+#### Improvements
+- feat: Excluding ip address when `AUDITLOG_DISABLE_REMOTE_ADDR` is set to True ([#620](https://github.com/jazzband/django-auditlog/pull/620))
+
+
+## 3.0.0-beta.4 (2024-01-02)
+
+#### Improvements
+
+- feat: If any receiver returns False, no logging will be made. This can be useful if logging should be conditionally enabled / disabled ([#590](https://github.com/jazzband/django-auditlog/pull/590))
+- Django: Confirm Django 5.0 support ([#598](https://github.com/jazzband/django-auditlog/pull/598))
+- Django: Drop Django 4.1 support ([#598](https://github.com/jazzband/django-auditlog/pull/598))
+
+## 3.0.0-beta.3 (2023-11-13)
+
+#### Improvements
+
+- Python: Confirm Python 3.12 support ([#572](https://github.com/jazzband/django-auditlog/pull/572))
+- feat: `thread.local` replaced with `ContextVar` to improve context managers in Django 4.2+
+
+## 3.0.0-beta.2 (2023-10-05)
+
+#### Breaking Changes
+- feat: stop deleting old log entries when a model with the same pk is created (i.e. the pk value is reused) ([#559](https://github.com/jazzband/django-auditlog/pull/559))
+
+#### Fixes
+* fix: don't set the correlation_id if the `AUDITLOG_CID_GETTER` is `None` ([#565](https://github.com/jazzband/django-auditlog/pull/565))
+
+## 3.0.0-beta.1
+
+#### Breaking Changes
+
+- feat: Change `LogEntry.change` field type to `JSONField` rather than `TextField`. This change include a migration that may take time to run depending on the number of records on your `LogEntry` table ([#407](https://github.com/jazzband/django-auditlog/pull/407))([#495](https://github.com/jazzband/django-auditlog/pull/495))
+- feat: Set `AuditlogHistoryField.delete_related` to `False` by default. This is different from the default configuration of Django's `GenericRelation`, but we should not erase the audit log of objects on deletion by default. ([#557](https://github.com/jazzband/django-auditlog/pull/557))
+- Python: Drop support for Python 3.7 ([#546](https://github.com/jazzband/django-auditlog/pull/546))
+
+#### Improvements
+
+- Changes the view when it has changes in fields `JSONField`. The `JSONField.encoder` is assigned to `json.dumps`. ([#489](https://github.com/jazzband/django-auditlog/pull/489))
+- feat: Added support for Correlation ID. ([#481](https://github.com/jazzband/django-auditlog/pull/481))
+- feat: Added pre-log and post-log signals. ([#483](https://github.com/jazzband/django-auditlog/pull/483))
+- feat: Make timestamp in LogEntry overwritable. ([#476](https://github.com/jazzband/django-auditlog/pull/476))
+- feat: Support excluding field names globally when ```AUDITLOG_INCLUDE_ALL_MODELS``` is enabled. ([#498](https://github.com/jazzband/django-auditlog/pull/498))
+- feat: Improved auto model registration to include auto-created models and exclude non-managed models, and automatically register m2m fields for models. ([#550](https://github.com/jazzband/django-auditlog/pull/550))
+
+#### 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))
+- fix: Make log entries read-only in the admin. ([#449](https://github.com/jazzband/django-auditlog/pull/449), [#556](https://github.com/jazzband/django-auditlog/pull/556)) (applied again after being reverted in 2.2.2)
+~~- fix: revert [#449](https://github.com/jazzband/django-auditlog/pull/449) "Make log entries read-only in the admin" as it breaks deletion of any auditlogged model through the admin when `AuditlogHistoryField` is used. ([#496](https://github.com/jazzband/django-auditlog/pull/496))~~
+- fix: Always set remote_addr even if the request has no authenticated user. ([#484](https://github.com/jazzband/django-auditlog/pull/484))
+- fix: Fix a bug in getting field's `verbose_name` when model is not accessible. ([508](https://github.com/jazzband/django-auditlog/pull/508))
+- fix: Fix a bug in `serialized_data` with F expressions. ([508](https://github.com/jazzband/django-auditlog/pull/508))
+
+## 2.2.2 (2023-01-16)
+
+No changes.
+
+## 2.2.1 (2022-11-28)
+
+#### Fixes
+
+- fix: Make log entries read-only in the admin. ([#449](https://github.com/jazzband/django-auditlog/pull/449))
+- fix: Handle IPv6 addresses in `X-Forwarded-For`. ([#457](https://github.com/jazzband/django-auditlog/pull/457))
+
+## 2.2.0 (2022-10-07)
+
+#### Improvements
+- feat: Add `ACCESS` action to `LogEntry` model and allow object access to be logged. ([#436](https://github.com/jazzband/django-auditlog/pull/436))
+- feat: Add `serialized_data` field on `LogEntry` model. ([#412](https://github.com/jazzband/django-auditlog/pull/412))
+- feat: Display the field name as it would be displayed in Django Admin or use `mapping_field` if available [#428](https://github.com/jazzband/django-auditlog/pull/428)
+- feat: New context manager `disable_auditlog` to turn off logging and a new setting `AUDITLOG_DISABLE_ON_RAW_SAVE`
+ to disable it during raw-save operations like loaddata. [#446](https://github.com/jazzband/django-auditlog/pull/446)
+- Python: Confirm Python 3.11 support ([#447](https://github.com/jazzband/django-auditlog/pull/447))
+- feat: Replace the `django.utils.timezone.utc` by `datetime.timezone.utc`. [#448](https://github.com/jazzband/django-auditlog/pull/448)
+
+#### Fixes
+
+- fix: Display `created` timestamp in server timezone ([#404](https://github.com/jazzband/django-auditlog/pull/404))
+- fix: Handle port in `remote_addr` ([#417](https://github.com/jazzband/django-auditlog/pull/417))
+- fix: Handle the error with AttributeError: 'OneToOneRel' error occur during a `PolymorphicModel` has relation with other models ([#429](https://github.com/jazzband/django-auditlog/pull/429))
+- fix: Support search by custom USERNAME_FIELD ([#432](https://github.com/jazzband/django-auditlog/pull/432))
+
+## 2.1.1 (2022-07-27)
+
+#### Improvements
+
+- feat: Display the diff for deleted objects in the admin ([#396](https://github.com/jazzband/django-auditlog/pull/396))
+- Django: Confirm Django 4.1 support ([#406](https://github.com/jazzband/django-auditlog/pull/406))
+
+#### Fixes
+
+- fix: Pin `python-dateutil` to 2.7.0 or higher for compatibility with Python 3.10 ([#401](https://github.com/jazzband/django-auditlog/pull/401))
+
## 2.1.0 (2022-06-27)
#### Improvements
diff --git a/README.md b/README.md
index 87d6a651..d0f65c49 100644
--- a/README.md
+++ b/README.md
@@ -3,11 +3,15 @@ django-auditlog
[](https://jazzband.co/)
[](https://github.com/jazzband/django-auditlog/actions)
-[](http://django-auditlog.readthedocs.org/en/latest/?badge=latest)
+[](https://django-auditlog.readthedocs.org/en/latest/?badge=latest)
[](https://codecov.io/gh/jazzband/django-auditlog)
[](https://pypi.python.org/pypi/django-auditlog)
[](https://pypi.python.org/pypi/django-auditlog)
+**Migrate to V3**
+
+Check the [Upgrading to version 3](https://django-auditlog.readthedocs.io/en/latest/upgrade.html) doc before upgrading to V3.
+
```django-auditlog``` (Auditlog) is a reusable app for Django that makes logging object changes a breeze. Auditlog tries to use as much as Python and Django's built in functionality to keep the list of dependencies as short as possible. Also, Auditlog aims to be fast and simple to use.
Auditlog is created out of the need for a simple Django app that logs changes to models along with the user who made the changes (later referred to as actor). Existing solutions seemed to offer a type of version control, which was found excessive and expensive in terms of database storage and performance.
@@ -32,8 +36,10 @@ If you have great ideas for Auditlog, or if you like to improve something, feel
Releases
--------
-1. Make sure all tests on `master` are green.
-2. Create a new branch `vX.Y.Z` from master for that specific release.
-3. Bump versions in `setup.py` and `docs/source/conf.py` (docs have 2 places where the versions need to be changed!)
-4. Pull request `vX.Y.Z` -> `master`.
-5. Pull request `master` -> `stable`. This merge triggers the deploy to pypi.
+1. Make sure all tests on `master` are green
+2. Create a new branch `vX.Y.Z` from master for that specific release
+3. Update the CHANGELOG release date
+4. Pull request `vX.Y.Z` -> `master`
+5. As a project lead, once the PR is merged, create and push a tag `vX.Y.Z`: this will trigger the release build and a notification will be sent from Jazzband of the availability of two packages (tgz and wheel)
+6. Test the install
+7. Publish the release to PyPI
diff --git a/auditlog/__init__.py b/auditlog/__init__.py
index 50e82385..0fd293e3 100644
--- a/auditlog/__init__.py
+++ b/auditlog/__init__.py
@@ -1,7 +1,3 @@
-from pkg_resources import DistributionNotFound, get_distribution
+from importlib.metadata import version
-try:
- __version__ = get_distribution("django-auditlog").version
-except DistributionNotFound:
- # package is not installed
- pass
+__version__ = version("django-auditlog")
diff --git a/auditlog/admin.py b/auditlog/admin.py
index 5be5f033..6d3e3f2a 100644
--- a/auditlog/admin.py
+++ b/auditlog/admin.py
@@ -1,11 +1,14 @@
+from functools import cached_property
+
from django.conf import settings
from django.contrib import admin
from django.contrib.auth import get_user_model
from django.core.paginator import Paginator
-from django.utils.functional import cached_property
+from django.utils.translation import gettext_lazy as _
from auditlog.count import limit_query_time
from auditlog.filters import (
+ CIDFilter,
FieldFilter,
ResourceTypeFilter,
ShortActorFilter,
@@ -26,15 +29,23 @@ def count(self):
return super().count
+@admin.register(LogEntry)
class LogEntryAdmin(admin.ModelAdmin, LogEntryAdminMixin):
- 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",
"changes",
"actor__first_name",
"actor__last_name",
- "actor__{}".format(get_user_model().USERNAME_FIELD),
+ f"actor__{get_user_model().USERNAME_FIELD}",
]
list_filter = [
"action",
@@ -42,11 +53,12 @@ class LogEntryAdmin(admin.ModelAdmin, LogEntryAdminMixin):
ResourceTypeFilter,
FieldFilter,
("timestamp", get_timestamp_filter()),
+ CIDFilter,
]
readonly_fields = ["created", "resource_url", "action", "user_url", "msg"]
fieldsets = [
- (None, {"fields": ["created", "user_url", "resource_url"]}),
- ("Changes", {"fields": ["action", "msg"]}),
+ (None, {"fields": ["created", "user_url", "resource_url", "cid"]}),
+ (_("Changes"), {"fields": ["action", "msg"]}),
]
list_select_related = ["actor", "content_type"]
show_full_result_count = False
@@ -58,8 +70,19 @@ def has_add_permission(self, request):
def has_change_permission(self, request, obj=None):
return False
+ @cached_property
+ def _own_url_names(self):
+ return [pattern.name for pattern in self.urls if pattern.name]
+
def has_delete_permission(self, request, obj=None):
+ if (
+ request.resolver_match
+ and request.resolver_match.url_name not in self._own_url_names
+ ):
+ # only allow cascade delete to satisfy delete_related flag
+ return super().has_delete_permission(request, obj)
return False
-
-admin.site.register(LogEntry, LogEntryAdmin)
+ def get_queryset(self, request):
+ self.request = request
+ return super().get_queryset(request=request)
diff --git a/auditlog/apps.py b/auditlog/apps.py
index 0e9266e3..aaae2768 100644
--- a/auditlog/apps.py
+++ b/auditlog/apps.py
@@ -1,12 +1,17 @@
from django.apps import AppConfig
+from django.utils.translation import gettext_lazy as _
class AuditlogConfig(AppConfig):
name = "auditlog"
- verbose_name = "Audit log"
+ verbose_name = _("Audit log")
default_auto_field = "django.db.models.AutoField"
def ready(self):
from auditlog.registry import auditlog
auditlog.register_from_settings()
+
+ from auditlog import models
+
+ models.changes_func = models._changes_func()
diff --git a/auditlog/cid.py b/auditlog/cid.py
new file mode 100644
index 00000000..8d2aa9f8
--- /dev/null
+++ b/auditlog/cid.py
@@ -0,0 +1,71 @@
+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 value of `AUDITLOG_CID_HEADER` in `request.headers` and in `request.META`.
+
+ This function doesn't do anything if the user is supplying their own `AUDITLOG_CID_GETTER`.
+
+ :param request: The request to get the cid from.
+ :return: None
+ """
+ if settings.AUDITLOG_CID_GETTER:
+ return
+
+ 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 fdb685c3..dbdfc5b4 100644
--- a/auditlog/conf.py
+++ b/auditlog/conf.py
@@ -15,3 +15,33 @@
settings.AUDITLOG_INCLUDE_TRACKING_MODELS = getattr(
settings, "AUDITLOG_INCLUDE_TRACKING_MODELS", ()
)
+
+# Exclude named fields across all models
+settings.AUDITLOG_EXCLUDE_TRACKING_FIELDS = getattr(
+ settings, "AUDITLOG_EXCLUDE_TRACKING_FIELDS", ()
+)
+
+# Disable on raw save to avoid logging imports and similar
+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)
+
+# migration
+settings.AUDITLOG_TWO_STEP_MIGRATION = getattr(
+ settings, "AUDITLOG_TWO_STEP_MIGRATION", False
+)
+settings.AUDITLOG_USE_TEXT_CHANGES_IF_JSON_IS_NOT_PRESENT = getattr(
+ settings, "AUDITLOG_USE_TEXT_CHANGES_IF_JSON_IS_NOT_PRESENT", False
+)
+
+# Disable remote_addr field in database
+settings.AUDITLOG_DISABLE_REMOTE_ADDR = getattr(
+ settings, "AUDITLOG_DISABLE_REMOTE_ADDR", False
+)
diff --git a/auditlog/context.py b/auditlog/context.py
index 6e9513db..ecb034c7 100644
--- a/auditlog/context.py
+++ b/auditlog/context.py
@@ -1,6 +1,6 @@
import contextlib
-import threading
import time
+from contextvars import ContextVar
from functools import partial
from django.contrib.auth import get_user_model
@@ -8,26 +8,27 @@
from auditlog.models import LogEntry
-threadlocal = threading.local()
+auditlog_value = ContextVar("auditlog_value")
+auditlog_disabled = ContextVar("auditlog_disabled", default=False)
@contextlib.contextmanager
-def set_actor(actor, remote_addr=None):
+def set_actor(actor, remote_addr=None, remote_port=None):
"""Connect a signal receiver with current user attached."""
# Initialize thread local storage
- threadlocal.auditlog = {
+ context_data = {
"signal_duid": ("set_actor", time.time()),
"remote_addr": remote_addr,
+ "remote_port": remote_port,
}
+ auditlog_value.set(context_data)
# Connect signal for automatic logging
- set_actor = partial(
- _set_actor, user=actor, signal_duid=threadlocal.auditlog["signal_duid"]
- )
+ set_actor = partial(_set_actor, user=actor, signal_duid=context_data["signal_duid"])
pre_save.connect(
set_actor,
sender=LogEntry,
- dispatch_uid=threadlocal.auditlog["signal_duid"],
+ dispatch_uid=context_data["signal_duid"],
weak=False,
)
@@ -35,12 +36,11 @@ def set_actor(actor, remote_addr=None):
yield
finally:
try:
- auditlog = threadlocal.auditlog
- except AttributeError:
+ auditlog = auditlog_value.get()
+ except LookupError:
pass
else:
pre_save.disconnect(sender=LogEntry, dispatch_uid=auditlog["signal_duid"])
- del threadlocal.auditlog
def _set_actor(user, sender, instance, signal_duid, **kwargs):
@@ -49,8 +49,8 @@ def _set_actor(user, sender, instance, signal_duid, **kwargs):
This function becomes a valid signal receiver when it is curried with the actor and a dispatch id.
"""
try:
- auditlog = threadlocal.auditlog
- except AttributeError:
+ auditlog = auditlog_value.get()
+ except LookupError:
pass
else:
if signal_duid != auditlog["signal_duid"]:
@@ -64,3 +64,16 @@ def _set_actor(user, sender, instance, signal_duid, **kwargs):
instance.actor = user
instance.remote_addr = auditlog["remote_addr"]
+ instance.remote_port = auditlog["remote_port"]
+
+
+@contextlib.contextmanager
+def disable_auditlog():
+ token = auditlog_disabled.set(True)
+ try:
+ yield
+ finally:
+ try:
+ auditlog_disabled.reset(token)
+ except LookupError:
+ pass
diff --git a/auditlog/diff.py b/auditlog/diff.py
index 83cd1aab..61f26cff 100644
--- a/auditlog/diff.py
+++ b/auditlog/diff.py
@@ -1,7 +1,11 @@
+import json
+from datetime import timezone
+from typing import Optional
+
from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist
from django.db.models import NOT_PROVIDED, DateTimeField, JSONField, Model
-from django.utils import timezone
+from django.utils import timezone as django_timezone
from django.utils.encoding import smart_str
@@ -62,15 +66,28 @@ def get_field_value(obj, field):
if isinstance(field, DateTimeField):
# DateTimeFields are timezone-aware, so we need to convert the field
# to its naive form before we can accurately compare them for changes.
- value = field.to_python(getattr(obj, field.name, None))
- if value is not None and settings.USE_TZ and not timezone.is_naive(value):
- value = timezone.make_naive(value, timezone=timezone.utc)
+ value = getattr(obj, field.name, None)
+ try:
+ value = field.to_python(value)
+ except TypeError:
+ return value
+ if (
+ value is not None
+ and settings.USE_TZ
+ and not django_timezone.is_naive(value)
+ ):
+ value = django_timezone.make_naive(value, timezone=timezone.utc)
elif isinstance(field, JSONField):
value = field.to_python(getattr(obj, field.name, None))
+ value = json.dumps(value, sort_keys=True, cls=field.encoder)
else:
value = smart_str(getattr(obj, field.name, None))
except ObjectDoesNotExist:
- value = field.default if field.default is not NOT_PROVIDED else None
+ value = (
+ field.default
+ if getattr(field, "default", NOT_PROVIDED) is not NOT_PROVIDED
+ else None
+ )
return value
@@ -88,7 +105,9 @@ def mask_str(value: str) -> str:
return "*" * mask_limit + value[mask_limit:]
-def model_instance_diff(old, new, fields_to_check=None):
+def model_instance_diff(
+ old: Optional[Model], new: Optional[Model], fields_to_check=None
+):
"""
Calculates the differences between two model instances. One of the instances may be ``None``
(i.e., a newly created model or deleted model). This will cause all fields with a value to have
diff --git a/auditlog/filters.py b/auditlog/filters.py
index 323876af..ce70216d 100644
--- a/auditlog/filters.py
+++ b/auditlog/filters.py
@@ -2,20 +2,20 @@
from django.contrib.admin import SimpleListFilter
from django.contrib.admin.filters import DateFieldListFilter
from django.contrib.contenttypes.models import ContentType
-from django.contrib.postgres.fields import JSONField
from django.db import connection
-from django.db.models import Value
+from django.db.models import JSONField, Value
from django.db.models.functions import Cast, Concat
+from django.utils.translation import gettext_lazy as _
from auditlog.registry import auditlog
class ShortActorFilter(SimpleListFilter):
- title = "Actor"
+ title = _("Actor")
parameter_name = "actor"
def lookups(self, request, model_admin):
- return [("null", "System"), ("not_null", "Users")]
+ return [("null", _("System")), ("not_null", _("Users"))]
def queryset(self, request, queryset):
value = self.value()
@@ -27,13 +27,12 @@ def queryset(self, request, queryset):
class ResourceTypeFilter(SimpleListFilter):
- title = "Resource Type"
+ title = _("Resource Type")
parameter_name = "resource_type"
def lookups(self, request, model_admin):
tracked_model_names = [
- "{}.{}".format(m._meta.app_label, m._meta.model_name)
- for m in auditlog.get_models()
+ f"{m._meta.app_label}.{m._meta.model_name}" for m in auditlog.get_models()
]
model_name_concat = Concat("app_label", Value("."), "model")
content_types = ContentType.objects.annotate(
@@ -50,7 +49,7 @@ def queryset(self, request, queryset):
class FieldFilter(SimpleListFilter):
- title = "Field"
+ title = _("Field")
parameter_name = "field"
parent = ResourceTypeFilter
@@ -81,7 +80,7 @@ def queryset(self, request, queryset):
if self.value() is None:
return queryset
return queryset.annotate(changes_json=Cast("changes", JSONField())).filter(
- **{"changes_json__{}__isnull".format(self.value()): False}
+ **{f"changes_json__{self.value()}__isnull": False}
)
@@ -96,3 +95,19 @@ def get_timestamp_filter():
pass
return DateFieldListFilter
+
+
+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/management/commands/auditlogmigratejson.py b/auditlog/management/commands/auditlogmigratejson.py
new file mode 100644
index 00000000..86caf25b
--- /dev/null
+++ b/auditlog/management/commands/auditlogmigratejson.py
@@ -0,0 +1,144 @@
+from math import ceil
+
+from django.conf import settings
+from django.core.management import CommandError, CommandParser
+from django.core.management.base import BaseCommand
+
+from auditlog.models import LogEntry
+
+
+class Command(BaseCommand):
+ help = "Migrates changes from changes_text to json changes."
+ requires_migrations_checks = True
+
+ def add_arguments(self, parser: CommandParser):
+ group = parser.add_argument_group()
+ group.add_argument(
+ "--check",
+ action="store_true",
+ help="Just check the status of the migration",
+ dest="check",
+ )
+ group.add_argument(
+ "-d",
+ "--database",
+ default=None,
+ metavar="The database engine",
+ help="If provided, the script will use native db operations. "
+ "Otherwise, it will use LogEntry.objects.bulk_update",
+ dest="db",
+ type=str,
+ choices=["postgres", "mysql", "oracle"],
+ )
+ group.add_argument(
+ "-b",
+ "--batch-size",
+ default=500,
+ help="Split the migration into multiple batches. If 0, then no batching will be done. "
+ "When passing a -d/database, the batch value will be ignored.",
+ dest="batch_size",
+ type=int,
+ )
+
+ def handle(self, *args, **options):
+ database = options["db"]
+ batch_size = options["batch_size"]
+ check = options["check"]
+
+ if (not self.check_logs()) or check:
+ return
+
+ if database:
+ result = self.migrate_using_sql(database)
+ self.stdout.write(
+ self.style.SUCCESS(
+ f"Updated {result} records using native database operations."
+ )
+ )
+ else:
+ result = self.migrate_using_django(batch_size)
+ self.stdout.write(
+ self.style.SUCCESS(f"Updated {result} records using django operations.")
+ )
+
+ self.check_logs()
+
+ def check_logs(self):
+ count = self.get_logs().count()
+ if count:
+ self.stdout.write(f"There are {count} records that needs migration.")
+ return True
+
+ self.stdout.write(self.style.SUCCESS("All records have been migrated."))
+ if settings.AUDITLOG_USE_TEXT_CHANGES_IF_JSON_IS_NOT_PRESENT:
+ var_msg = self.style.WARNING(
+ "AUDITLOG_USE_TEXT_CHANGES_IF_JSON_IS_NOT_PRESENT"
+ )
+ self.stdout.write(f"You can now set {var_msg} to False.")
+
+ return False
+
+ def get_logs(self):
+ return LogEntry.objects.filter(
+ changes_text__isnull=False, changes__isnull=True
+ ).exclude(changes_text__exact="")
+
+ def migrate_using_django(self, batch_size):
+ def _apply_django_migration(_logs) -> int:
+ import json
+
+ updated = []
+ errors = []
+ for log in _logs:
+ try:
+ log.changes = json.loads(log.changes_text)
+ except ValueError:
+ errors.append(log.id)
+ else:
+ updated.append(log)
+
+ LogEntry.objects.bulk_update(updated, fields=["changes"])
+ if errors:
+ self.stderr.write(
+ self.style.ERROR(
+ f"ValueError was raised while converting the logs with these ids into json."
+ f"They where not be included in this migration batch."
+ f"\n"
+ f"{errors}"
+ )
+ )
+ return len(updated)
+
+ logs = self.get_logs()
+
+ if not batch_size:
+ return _apply_django_migration(logs)
+
+ total_updated = 0
+ for _ in range(ceil(logs.count() / batch_size)):
+ total_updated += _apply_django_migration(self.get_logs()[:batch_size])
+ return total_updated
+
+ def migrate_using_sql(self, database):
+ from django.db import connection
+
+ def postgres():
+ with connection.cursor() as cursor:
+ cursor.execute(
+ """
+ UPDATE auditlog_logentry
+ SET changes="changes_text"::jsonb
+ WHERE changes_text IS NOT NULL
+ AND changes_text <> ''
+ AND changes IS NULL
+ """
+ )
+ return cursor.cursor.rowcount
+
+ if database == "postgres":
+ return postgres()
+
+ raise CommandError(
+ f"Migrating the records using {database} is not implemented. "
+ f"Run this management command without passing a -d/--database argument."
+ )
diff --git a/auditlog/middleware.py b/auditlog/middleware.py
index aa7b1236..bd01da39 100644
--- a/auditlog/middleware.py
+++ b/auditlog/middleware.py
@@ -1,5 +1,9 @@
-import contextlib
+from typing import Optional
+from django.conf import settings
+from django.contrib.auth import get_user_model
+
+from auditlog.cid import set_cid
from auditlog.context import set_actor
@@ -11,19 +15,53 @@ class AuditlogMiddleware:
def __init__(self, get_response=None):
self.get_response = get_response
+ if not isinstance(settings.AUDITLOG_DISABLE_REMOTE_ADDR, bool):
+ raise TypeError("Setting 'AUDITLOG_DISABLE_REMOTE_ADDR' must be a boolean")
- def __call__(self, request):
+ @staticmethod
+ def _get_remote_addr(request):
+ if settings.AUDITLOG_DISABLE_REMOTE_ADDR:
+ return None
+
+ # In case there is no proxy, return the original address
+ if not request.headers.get("X-Forwarded-For"):
+ return request.META.get("REMOTE_ADDR")
+
+ # In case of proxy, set 'original' address
+ remote_addr: str = request.headers.get("X-Forwarded-For").split(",")[0]
+
+ # Remove port number from remote_addr
+ if "." in remote_addr and ":" in remote_addr: # IPv4 with port (`x.x.x.x:x`)
+ remote_addr = remote_addr.split(":")[0]
+ elif "[" in remote_addr: # IPv6 with port (`[:::]:x`)
+ remote_addr = remote_addr[1:].split("]")[0]
+
+ return remote_addr
- if request.META.get("HTTP_X_FORWARDED_FOR"):
- # In case of proxy, set 'original' address
- remote_addr = request.META.get("HTTP_X_FORWARDED_FOR").split(",")[0]
- else:
- remote_addr = request.META.get("REMOTE_ADDR")
+ @staticmethod
+ def _get_remote_port(request) -> Optional[int]:
+ remote_port = request.headers.get("X-Forwarded-Port", "")
+
+ try:
+ remote_port = int(remote_port)
+ except ValueError:
+ remote_port = None
+
+ return remote_port
+
+ @staticmethod
+ def _get_actor(request):
+ user = getattr(request, "user", None)
+ if isinstance(user, get_user_model()) and user.is_authenticated:
+ 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)
- if hasattr(request, "user") and request.user.is_authenticated:
- context = set_actor(actor=request.user, remote_addr=remote_addr)
- else:
- context = contextlib.nullcontext()
+ set_cid(request)
- with context:
+ with set_actor(actor=user, remote_addr=remote_addr, remote_port=remote_port):
return self.get_response(request)
diff --git a/auditlog/migrations/0001_initial.py b/auditlog/migrations/0001_initial.py
index 22b1ee4a..6821c2d5 100644
--- a/auditlog/migrations/0001_initial.py
+++ b/auditlog/migrations/0001_initial.py
@@ -4,7 +4,6 @@
class Migration(migrations.Migration):
-
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("contenttypes", "0001_initial"),
diff --git a/auditlog/migrations/0002_auto_support_long_primary_keys.py b/auditlog/migrations/0002_auto_support_long_primary_keys.py
index e767b92f..5ab56893 100644
--- a/auditlog/migrations/0002_auto_support_long_primary_keys.py
+++ b/auditlog/migrations/0002_auto_support_long_primary_keys.py
@@ -2,7 +2,6 @@
class Migration(migrations.Migration):
-
dependencies = [
("auditlog", "0001_initial"),
]
diff --git a/auditlog/migrations/0003_logentry_remote_addr.py b/auditlog/migrations/0003_logentry_remote_addr.py
index c363e82b..b66bcd8a 100644
--- a/auditlog/migrations/0003_logentry_remote_addr.py
+++ b/auditlog/migrations/0003_logentry_remote_addr.py
@@ -2,7 +2,6 @@
class Migration(migrations.Migration):
-
dependencies = [
("auditlog", "0002_auto_support_long_primary_keys"),
]
diff --git a/auditlog/migrations/0004_logentry_detailed_object_repr.py b/auditlog/migrations/0004_logentry_detailed_object_repr.py
index b3762b93..d49b198e 100644
--- a/auditlog/migrations/0004_logentry_detailed_object_repr.py
+++ b/auditlog/migrations/0004_logentry_detailed_object_repr.py
@@ -2,7 +2,6 @@
class Migration(migrations.Migration):
-
dependencies = [
("auditlog", "0003_logentry_remote_addr"),
]
diff --git a/auditlog/migrations/0005_logentry_additional_data_verbose_name.py b/auditlog/migrations/0005_logentry_additional_data_verbose_name.py
index f0ae841b..123080f7 100644
--- a/auditlog/migrations/0005_logentry_additional_data_verbose_name.py
+++ b/auditlog/migrations/0005_logentry_additional_data_verbose_name.py
@@ -2,7 +2,6 @@
class Migration(migrations.Migration):
-
dependencies = [
("auditlog", "0004_logentry_detailed_object_repr"),
]
diff --git a/auditlog/migrations/0006_object_pk_index.py b/auditlog/migrations/0006_object_pk_index.py
index 729ebe20..a5c47b35 100644
--- a/auditlog/migrations/0006_object_pk_index.py
+++ b/auditlog/migrations/0006_object_pk_index.py
@@ -2,7 +2,6 @@
class Migration(migrations.Migration):
-
dependencies = [
("auditlog", "0005_logentry_additional_data_verbose_name"),
]
diff --git a/auditlog/migrations/0007_object_pk_type.py b/auditlog/migrations/0007_object_pk_type.py
index d6514e4e..97dc7812 100644
--- a/auditlog/migrations/0007_object_pk_type.py
+++ b/auditlog/migrations/0007_object_pk_type.py
@@ -2,7 +2,6 @@
class Migration(migrations.Migration):
-
dependencies = [
("auditlog", "0006_object_pk_index"),
]
diff --git a/auditlog/migrations/0008_timestamp_index.py b/auditlog/migrations/0008_timestamp_index.py
index 88c1128f..331c6c13 100644
--- a/auditlog/migrations/0008_timestamp_index.py
+++ b/auditlog/migrations/0008_timestamp_index.py
@@ -1,9 +1,7 @@
-# -*- coding: utf-8 -*-
from django.db import migrations, models
class Migration(migrations.Migration):
-
dependencies = [
("auditlog", "0007_object_pk_type"),
]
diff --git a/auditlog/migrations/0009_timestamp_id_index.py b/auditlog/migrations/0009_timestamp_id_index.py
index 1afce246..d2bcdf1a 100644
--- a/auditlog/migrations/0009_timestamp_id_index.py
+++ b/auditlog/migrations/0009_timestamp_id_index.py
@@ -1,9 +1,7 @@
-# -*- coding: utf-8 -*-
from django.db import migrations, models
class Migration(migrations.Migration):
-
dependencies = [
("auditlog", "0008_timestamp_index"),
]
diff --git a/auditlog/migrations/0010_action_index.py b/auditlog/migrations/0010_action_index.py
index 048fc156..984ca10b 100644
--- a/auditlog/migrations/0010_action_index.py
+++ b/auditlog/migrations/0010_action_index.py
@@ -2,7 +2,6 @@
class Migration(migrations.Migration):
-
dependencies = [
("auditlog", "0009_timestamp_id_index"),
]
diff --git a/auditlog/migrations/0011_alter_logentry_additional_data.py b/auditlog/migrations/0011_alter_logentry_additional_data.py
index 07229059..af6623f3 100644
--- a/auditlog/migrations/0011_alter_logentry_additional_data.py
+++ b/auditlog/migrations/0011_alter_logentry_additional_data.py
@@ -2,7 +2,6 @@
class Migration(migrations.Migration):
-
dependencies = [
("auditlog", "0010_action_index"),
]
diff --git a/auditlog/migrations/0012_alter_logentry_timestamp.py b/auditlog/migrations/0012_alter_logentry_timestamp.py
index 8768ec13..cca7cd94 100644
--- a/auditlog/migrations/0012_alter_logentry_timestamp.py
+++ b/auditlog/migrations/0012_alter_logentry_timestamp.py
@@ -4,7 +4,6 @@
class Migration(migrations.Migration):
-
dependencies = [
("auditlog", "0011_alter_logentry_additional_data"),
]
diff --git a/auditlog/migrations/0013_logentry_serialized_data.py b/auditlog/migrations/0013_logentry_serialized_data.py
new file mode 100644
index 00000000..9c30a4b6
--- /dev/null
+++ b/auditlog/migrations/0013_logentry_serialized_data.py
@@ -0,0 +1,17 @@
+# Generated by Django 4.0 on 2022-08-05 19:55
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("auditlog", "0012_alter_logentry_timestamp"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="logentry",
+ name="serialized_data",
+ field=models.JSONField(null=True),
+ ),
+ ]
diff --git a/auditlog/migrations/0014_add_logentry_action_access.py b/auditlog/migrations/0014_add_logentry_action_access.py
new file mode 100644
index 00000000..39f1fe14
--- /dev/null
+++ b/auditlog/migrations/0014_add_logentry_action_access.py
@@ -0,0 +1,21 @@
+# Generated by Django 4.1.1 on 2022-10-13 07:56
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("auditlog", "0013_logentry_serialized_data"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="logentry",
+ name="action",
+ field=models.PositiveSmallIntegerField(
+ choices=[(0, "create"), (1, "update"), (2, "delete"), (3, "access")],
+ db_index=True,
+ verbose_name="action",
+ ),
+ ),
+ ]
diff --git a/auditlog/migrations/0015_alter_logentry_timestamp.py b/auditlog/migrations/0015_alter_logentry_timestamp.py
new file mode 100644
index 00000000..e2b687ca
--- /dev/null
+++ b/auditlog/migrations/0015_alter_logentry_timestamp.py
@@ -0,0 +1,22 @@
+# Generated by Django 4.1.4 on 2022-12-15 21:24
+
+import django.utils.timezone
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("auditlog", "0014_add_logentry_action_access"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="logentry",
+ name="timestamp",
+ field=models.DateTimeField(
+ db_index=True,
+ default=django.utils.timezone.now,
+ verbose_name="timestamp",
+ ),
+ ),
+ ]
diff --git a/auditlog/migrations/0016_logentry_cid.py b/auditlog/migrations/0016_logentry_cid.py
new file mode 100644
index 00000000..638c1938
--- /dev/null
+++ b/auditlog/migrations/0016_logentry_cid.py
@@ -0,0 +1,23 @@
+# Generated by Django 4.1.4 on 2022-12-18 13:24
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("auditlog", "0015_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/migrations/0017_alter_logentry_changes.py b/auditlog/migrations/0017_alter_logentry_changes.py
new file mode 100644
index 00000000..78f7447e
--- /dev/null
+++ b/auditlog/migrations/0017_alter_logentry_changes.py
@@ -0,0 +1,40 @@
+# Generated by Django 4.0 on 2022-08-04 15:41
+from django.conf import settings
+from django.db import migrations, models
+
+
+def two_step_migrations() -> list:
+ if settings.AUDITLOG_TWO_STEP_MIGRATION:
+ return [
+ migrations.RenameField(
+ model_name="logentry",
+ old_name="changes",
+ new_name="changes_text",
+ ),
+ migrations.AddField(
+ model_name="logentry",
+ name="changes",
+ field=models.JSONField(null=True, verbose_name="change message"),
+ ),
+ ]
+
+ return [
+ migrations.AddField(
+ model_name="logentry",
+ name="changes_text",
+ field=models.TextField(blank=True, verbose_name="change message"),
+ ),
+ migrations.AlterField(
+ model_name="logentry",
+ name="changes",
+ field=models.JSONField(null=True, verbose_name="change message"),
+ ),
+ ]
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("auditlog", "0016_logentry_cid"),
+ ]
+
+ operations = [*two_step_migrations()]
diff --git a/auditlog/migrations/0018_logentry_remote_port.py b/auditlog/migrations/0018_logentry_remote_port.py
new file mode 100644
index 00000000..0bfeaef9
--- /dev/null
+++ b/auditlog/migrations/0018_logentry_remote_port.py
@@ -0,0 +1,17 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("auditlog", "0017_alter_logentry_changes"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="logentry",
+ name="remote_port",
+ field=models.PositiveIntegerField(
+ blank=True, null=True, verbose_name="remote port"
+ ),
+ ),
+ ]
diff --git a/auditlog/migrations/0019_rename_logentry_idx.py b/auditlog/migrations/0019_rename_logentry_idx.py
new file mode 100644
index 00000000..dbad3a95
--- /dev/null
+++ b/auditlog/migrations/0019_rename_logentry_idx.py
@@ -0,0 +1,15 @@
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("auditlog", "0018_logentry_remote_port"),
+ ]
+
+ operations = [
+ migrations.RenameIndex(
+ model_name="logentry",
+ new_name="auditlog_timestamp_id_idx",
+ old_fields=("timestamp", "id"),
+ ),
+ ]
diff --git a/auditlog/mixins.py b/auditlog/mixins.py
index e9b61586..422e0970 100644
--- a/auditlog/mixins.py
+++ b/auditlog/mixins.py
@@ -1,26 +1,34 @@
-import json
-
from django import urls as urlresolvers
from django.conf import settings
+from django.contrib import admin
+from django.core.exceptions import FieldDoesNotExist
from django.db.models import DateTimeField
+from django.forms.utils import pretty_name
+from django.http import HttpRequest
from django.template.defaultfilters import pluralize
from django.urls.exceptions import NoReverseMatch
from django.utils import timezone
from django.utils.html import format_html, format_html_join
from django.utils.safestring import mark_safe
from django.utils.timezone import localtime
+from django.utils.translation import gettext_lazy as _
from auditlog.models import LogEntry
+from auditlog.registry import auditlog
+from auditlog.signals import accessed
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).strftime("%Y-%m-%d %H:%M:%S")
- created.short_description = "Created"
-
+ @admin.display(description=_("User"))
def user_url(self, obj):
if obj.actor:
app_label, model = settings.AUTH_USER_MODEL.split(".")
@@ -33,8 +41,7 @@ def user_url(self, obj):
return "system"
- user_url.short_description = "User"
-
+ @admin.display(description=_("Resource"))
def resource_url(self, obj):
app_label, model = obj.content_type.app_label, obj.content_type.model
viewname = f"admin:{app_label}_{model}_change"
@@ -48,12 +55,11 @@ def resource_url(self, obj):
'{} - {}', link, obj.content_type, obj.object_repr
)
- resource_url.short_description = "Resource"
-
+ @admin.display(description=_("Changes"))
def msg_short(self, obj):
- if obj.action == LogEntry.Action.DELETE:
+ if obj.action in [LogEntry.Action.DELETE, LogEntry.Action.ACCESS]:
return "" # delete
- changes = json.loads(obj.changes)
+ changes = obj.changes_dict
s = "" if len(changes) == 1 else "s"
fields = ", ".join(changes.keys())
if len(fields) > MAX:
@@ -61,12 +67,9 @@ def msg_short(self, obj):
fields = fields[:i] + " .."
return "%d change%s: %s" % (len(changes), s, fields)
- msg_short.short_description = "Changes"
-
+ @admin.display(description=_("Changes"))
def msg(self, obj):
- if obj.action == LogEntry.Action.DELETE:
- return "" # delete
- changes = json.loads(obj.changes)
+ changes = obj.changes_dict
atom_changes = {}
m2m_changes = {}
@@ -88,7 +91,9 @@ def msg(self, obj):
msg.append("
")
msg.append(self._format_header("#", "Field", "From", "To"))
for i, (field, change) in enumerate(sorted(atom_changes.items()), 1):
- value = [i, field] + (["***", "***"] if field == "password" else change)
+ value = [i, self.field_verbose_name(obj, field)] + (
+ ["***", "***"] if field == "password" else change
+ )
if field in datetime_fields:
spotted_datetime_field = True
msg.append(self._format_line(*value))
@@ -108,7 +113,7 @@ def msg(self, obj):
format_html(
"| {} | {} | {} | {} |
",
i,
- field,
+ self.field_verbose_name(obj, field),
change["operation"],
change_html,
)
@@ -123,8 +128,6 @@ def msg(self, obj):
return mark_safe("".join(msg))
- msg.short_description = "Changes"
-
def _get_datetime_fields(self, obj):
# only works for existing models and existing fields
try:
@@ -136,6 +139,15 @@ def _get_datetime_fields(self, obj):
except Exception:
return set()
+ @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
@@ -154,11 +166,42 @@ def _get_timezone_warning(self):
ahead_behind = "ahead of" if offset_seconds < 0 else "behind"
offset_seconds = abs(offset_seconds)
hours, minutes = divmod(int(offset_seconds / 60), 60)
- hours = "{} hour{}".format(hours, pluralize(hours))
- minutes = " {} minute{}".format(minutes, pluralize(minutes)) if minutes else ""
+ hours = f"{hours} hour{pluralize(hours)}"
+ minutes = f" {minutes} minute{pluralize(minutes)}" if minutes else ""
warning_message = (
"Note: The timestamps are in UTC, which is {}{} {} server time".format(
hours, minutes, ahead_behind
)
)
- return '{}'.format(warning_message)
+ return f'{warning_message}'
+
+ def field_verbose_name(self, obj, field_name: str):
+ model = obj.content_type.model_class()
+ if model is None:
+ return field_name
+ try:
+ model_fields = auditlog.get_model_fields(model._meta.model)
+ mapping_field_name = model_fields["mapping_fields"].get(field_name)
+ if mapping_field_name:
+ return mapping_field_name
+ except KeyError:
+ # Model definition in auditlog was probably removed
+ pass
+ try:
+ field = model._meta.get_field(field_name)
+ return pretty_name(getattr(field, "verbose_name", field_name))
+ 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):
+ obj = self.get_object()
+ accessed.send(obj.__class__, instance=obj)
+ return super().render_to_response(context, **response_kwargs)
diff --git a/auditlog/models.py b/auditlog/models.py
index 44589441..614ed24c 100644
--- a/auditlog/models.py
+++ b/auditlog/models.py
@@ -1,44 +1,59 @@
import ast
+import contextlib
import json
+from copy import deepcopy
+from datetime import timezone
+from typing import Any, Callable
from dateutil import parser
from dateutil.tz import gettz
from django.conf import settings
from django.contrib.contenttypes.fields import GenericRelation
from django.contrib.contenttypes.models import ContentType
-from django.core.exceptions import FieldDoesNotExist
+from django.core import serializers
+from django.core.exceptions import FieldDoesNotExist, ValidationError
from django.db import DEFAULT_DB_ALIAS, models
from django.db.models import Q, QuerySet
-from django.utils import formats, timezone
+from django.utils import formats
+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.diff import mask_str
+
class LogEntryManager(models.Manager):
"""
Custom manager for the :py:class:`LogEntry` model.
"""
- def log_create(self, instance, **kwargs):
+ def log_create(self, instance, force_log: bool = False, **kwargs):
"""
Helper method to create a new log entry. This method automatically populates some fields when no
explicit value is given.
:param instance: The model instance to log a change for.
:type instance: Model
+ :param force_log: Create a LogEntry even if no changes exist.
+ :type force_log: bool
:param kwargs: Field overrides for the :py:class:`LogEntry` object.
: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)
- if changes is not None:
+ if changes is not None or force_log:
kwargs.setdefault(
"content_type", ContentType.objects.get_for_model(instance)
)
kwargs.setdefault("object_pk", pk)
kwargs.setdefault("object_repr", smart_str(instance))
+ kwargs.setdefault(
+ "serialized_data", self._get_serialized_data_or_none(instance)
+ )
if isinstance(pk, int):
kwargs.setdefault("object_id", pk)
@@ -47,25 +62,8 @@ def log_create(self, instance, **kwargs):
if callable(get_additional_data):
kwargs.setdefault("additional_data", get_additional_data())
- # Delete log entries with the same pk as a newly created model.
- # This should only be necessary when an pk is used twice.
- if kwargs.get("action", None) is LogEntry.Action.CREATE:
- if (
- kwargs.get("object_id", None) is not None
- and self.filter(
- content_type=kwargs.get("content_type"),
- object_id=kwargs.get("object_id"),
- ).exists()
- ):
- self.filter(
- content_type=kwargs.get("content_type"),
- object_id=kwargs.get("object_id"),
- ).delete()
- else:
- self.filter(
- 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
@@ -86,9 +84,10 @@ def log_m2m_changes(
: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:
+ if changed_queryset:
kwargs.setdefault(
"content_type", ContentType.objects.get_for_model(instance)
)
@@ -104,15 +103,15 @@ def log_m2m_changes(
kwargs.setdefault("additional_data", get_additional_data())
objects = [smart_str(instance) for instance in changed_queryset]
- kwargs["changes"] = json.dumps(
- {
- field_name: {
- "type": "m2m",
- "operation": operation,
- "objects": objects,
- }
+ kwargs["changes"] = {
+ field_name: {
+ "type": "m2m",
+ "operation": operation,
+ "objects": objects,
}
- )
+ }
+
+ kwargs.setdefault("cid", get_cid())
return self.create(**kwargs)
return None
@@ -203,11 +202,90 @@ def _get_pk_value(self, instance):
pk_field = instance._meta.pk.name
pk = getattr(instance, pk_field, None)
- # Check to make sure that we got an pk not a model object.
+ # Check to make sure that we got a pk not a model object.
if isinstance(pk, models.Model):
pk = self._get_pk_value(pk)
return pk
+ def _get_serialized_data_or_none(self, instance):
+ from auditlog.registry import auditlog
+
+ if not auditlog.contains(instance.__class__):
+ return None
+
+ opts = auditlog.get_serialize_options(instance.__class__)
+ if not opts["serialize_data"]:
+ return None
+
+ model_fields = auditlog.get_model_fields(instance.__class__)
+ kwargs = opts.get("serialize_kwargs", {})
+
+ if opts["serialize_auditlog_fields_only"]:
+ kwargs.setdefault(
+ "fields", self._get_applicable_model_fields(instance, model_fields)
+ )
+
+ instance_copy = self._get_copy_with_python_typed_fields(instance)
+ data = dict(
+ json.loads(serializers.serialize("json", (instance_copy,), **kwargs))[0]
+ )
+
+ mask_fields = model_fields["mask_fields"]
+ if mask_fields:
+ data = self._mask_serialized_fields(data, mask_fields)
+
+ return data
+
+ def _get_copy_with_python_typed_fields(self, instance):
+ """
+ Attempt to create copy of instance and coerce types on instance fields
+
+ The Django core serializer assumes that the values on object fields are
+ correctly typed to their respective fields. Updates made to an object's
+ in-memory state may not meet this assumption. To prevent this violation, values
+ are typed by calling `to_python` from the field object, the result is set on a
+ copy of the instance and the copy is sent to the serializer.
+ """
+ try:
+ instance_copy = deepcopy(instance)
+ except TypeError:
+ instance_copy = instance
+ for field in instance_copy._meta.fields:
+ if not field.is_relation:
+ value = getattr(instance_copy, field.name)
+ try:
+ setattr(instance_copy, field.name, field.to_python(value))
+ except ValidationError:
+ continue
+ return instance_copy
+
+ def _get_applicable_model_fields(
+ self, instance, model_fields: dict[str, list[str]]
+ ) -> list[str]:
+ include_fields = model_fields["include_fields"]
+ exclude_fields = model_fields["exclude_fields"]
+ all_field_names = [field.name for field in instance._meta.fields]
+
+ if not include_fields and not exclude_fields:
+ return all_field_names
+
+ return list(set(include_fields or all_field_names).difference(exclude_fields))
+
+ def _mask_serialized_fields(
+ self, data: dict[str, Any], mask_fields: list[str]
+ ) -> dict[str, Any]:
+ all_field_data = data.pop("fields")
+
+ masked_field_data = {}
+ for key, value in all_field_data.items():
+ if isinstance(value, str) and key in mask_fields:
+ masked_field_data[key] = mask_str(value)
+ else:
+ masked_field_data[key] = value
+
+ data["fields"] = masked_field_data
+ return data
+
class LogEntry(models.Model):
"""
@@ -227,17 +305,20 @@ class Action:
action. This may be useful in some cases when comparing actions because the ``__lt``, ``__lte``,
``__gt``, ``__gte`` lookup filters can be used in queries.
- The valid actions are :py:attr:`Action.CREATE`, :py:attr:`Action.UPDATE` and :py:attr:`Action.DELETE`.
+ The valid actions are :py:attr:`Action.CREATE`, :py:attr:`Action.UPDATE`,
+ :py:attr:`Action.DELETE` and :py:attr:`Action.ACCESS`.
"""
CREATE = 0
UPDATE = 1
DELETE = 2
+ ACCESS = 3
choices = (
(CREATE, _("create")),
(UPDATE, _("update")),
(DELETE, _("delete")),
+ (ACCESS, _("access")),
)
content_type = models.ForeignKey(
@@ -253,10 +334,12 @@ class Action:
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=Action.choices, verbose_name=_("action"), db_index=True
)
- changes = models.TextField(blank=True, verbose_name=_("change message"))
+ changes_text = models.TextField(blank=True, verbose_name=_("change message"))
+ changes = models.JSONField(null=True, verbose_name=_("change message"))
actor = models.ForeignKey(
to=settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
@@ -265,11 +348,23 @@ class Action:
related_name="+",
verbose_name=_("actor"),
)
+ cid = models.CharField(
+ max_length=255,
+ db_index=True,
+ null=True,
+ blank=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, auto_now_add=True, verbose_name=_("timestamp")
+ default=django_timezone.now,
+ db_index=True,
+ verbose_name=_("timestamp"),
)
additional_data = models.JSONField(
blank=True, null=True, verbose_name=_("additional data")
@@ -282,7 +377,9 @@ class Meta:
ordering = ["-timestamp"]
verbose_name = _("log entry")
verbose_name_plural = _("log entries")
- index_together = ("timestamp", "id")
+ indexes = [
+ models.Index(fields=["timestamp", "id"], name="auditlog_timestamp_id_idx"),
+ ]
def __str__(self):
if self.action == self.Action.CREATE:
@@ -301,10 +398,7 @@ def changes_dict(self):
"""
:return: The changes recorded in this log entry as a dictionary object.
"""
- try:
- return json.loads(self.changes)
- except ValueError:
- return {}
+ return changes_func(self)
@property
def changes_str(self, colon=": ", arrow=" \u2192 ", separator="; "):
@@ -337,11 +431,14 @@ def changes_display_dict(self):
"""
:return: The changes recorded in this log entry intended for display to users as a dictionary object.
"""
- # Get the model and model_fields
from auditlog.registry import auditlog
+ # Get the model and model_fields, but gracefully handle the case where the model no longer exists
model = self.content_type.model_class()
- model_fields = auditlog.get_model_fields(model._meta.model)
+ model_fields = None
+ if auditlog.contains(model._meta.model):
+ model_fields = auditlog.get_model_fields(model._meta.model)
+
changes_display_dict = {}
# grab the changes_dict and iterate through
for field_name, values in self.changes_dict.items():
@@ -352,7 +449,7 @@ def changes_display_dict(self):
changes_display_dict[field_name] = values
continue
values_display = []
- # handle choices fields and Postgres ArrayField to get human readable version
+ # handle choices fields and Postgres ArrayField to get human-readable version
choices_dict = None
if getattr(field, "choices", []):
choices_dict = dict(field.choices)
@@ -399,9 +496,13 @@ def changes_display_dict(self):
value = f"{value[:140]}..."
values_display.append(value)
- verbose_name = model_fields["mapping_fields"].get(
- field.name, getattr(field, "verbose_name", field.name)
- )
+
+ # Use verbose_name from mapping if available, otherwise determine from field
+ if model_fields and field.name in model_fields["mapping_fields"]:
+ verbose_name = model_fields["mapping_fields"][field.name]
+ else:
+ verbose_name = getattr(field, "verbose_name", field.name)
+
changes_display_dict[verbose_name] = values_display
return changes_display_dict
@@ -411,7 +512,7 @@ class AuditlogHistoryField(GenericRelation):
A subclass of py:class:`django.contrib.contenttypes.fields.GenericRelation` that sets some default
variables. This makes it easier to access Auditlog's log entries, for example in templates.
- By default this field will assume that your primary keys are numeric, simply because this is the most
+ By default, this field will assume that your primary keys are numeric, simply because this is the most
common case. However, if you have a non-integer primary key, you can simply pass ``pk_indexable=False``
to the constructor, and Auditlog will fall back to using a non-indexed text based field for this model.
@@ -420,14 +521,12 @@ class AuditlogHistoryField(GenericRelation):
:param pk_indexable: Whether the primary key for this model is not an :py:class:`int` or :py:class:`long`.
:type pk_indexable: bool
- :param delete_related: By default, including a generic relation into a model will cause all related
- objects to be cascade-deleted when the parent object is deleted. Passing False to this overrides this
- behavior, retaining the full auditlog history for the object. Defaults to True, because that's
- Django's default behavior.
+ :param delete_related: Delete referenced auditlog entries together with the tracked object.
+ Defaults to False to keep the integrity of the auditlog.
:type delete_related: bool
"""
- def __init__(self, pk_indexable=True, delete_related=True, **kwargs):
+ def __init__(self, pk_indexable=True, delete_related=False, **kwargs):
kwargs["to"] = LogEntry
if pk_indexable:
@@ -450,3 +549,24 @@ def bulk_related_objects(self, objs, using=DEFAULT_DB_ALIAS):
# method. However, because we don't want to delete these related
# objects, we simply return an empty list.
return []
+
+
+# should I add a signal receiver for setting_changed?
+changes_func = None
+
+
+def _changes_func() -> Callable[[LogEntry], dict]:
+ def json_then_text(instance: LogEntry) -> dict:
+ if instance.changes:
+ return instance.changes
+ elif instance.changes_text:
+ with contextlib.suppress(ValueError):
+ return json.loads(instance.changes_text)
+ return {}
+
+ def default(instance: LogEntry) -> dict:
+ return instance.changes or {}
+
+ if settings.AUDITLOG_USE_TEXT_CHANGES_IF_JSON_IS_NOT_PRESENT:
+ return json_then_text
+ return default
diff --git a/auditlog/receivers.py b/auditlog/receivers.py
index b6e867d5..53d0fb07 100644
--- a/auditlog/receivers.py
+++ b/auditlog/receivers.py
@@ -1,9 +1,35 @@
-import json
+from functools import wraps
+from django.conf import settings
+
+from auditlog.context import auditlog_disabled
from auditlog.diff import model_instance_diff
from auditlog.models import LogEntry
+from auditlog.signals import post_log, pre_log
+
+
+def check_disable(signal_handler):
+ """
+ Decorator that passes along disabled in kwargs if any of the following is true:
+ - 'auditlog_disabled' from threadlocal is true
+ - raw = True and AUDITLOG_DISABLE_ON_RAW_SAVE is True
+ """
+
+ @wraps(signal_handler)
+ def wrapper(*args, **kwargs):
+ try:
+ auditlog_disabled_value = auditlog_disabled.get()
+ except LookupError:
+ auditlog_disabled_value = False
+ if not auditlog_disabled_value and not (
+ kwargs.get("raw") and settings.AUDITLOG_DISABLE_ON_RAW_SAVE
+ ):
+ signal_handler(*args, **kwargs)
+
+ return wrapper
+@check_disable
def log_create(sender, instance, created, **kwargs):
"""
Signal receiver that creates a log entry when a model instance is first saved to the database.
@@ -11,40 +37,36 @@ def log_create(sender, instance, created, **kwargs):
Direct use is discouraged, connect your model through :py:func:`auditlog.registry.register` instead.
"""
if created:
- changes = model_instance_diff(None, instance)
-
- LogEntry.objects.log_create(
- instance,
+ _create_log_entry(
action=LogEntry.Action.CREATE,
- changes=json.dumps(changes),
+ instance=instance,
+ sender=sender,
+ diff_old=None,
+ diff_new=instance,
)
+@check_disable
def log_update(sender, instance, **kwargs):
"""
Signal receiver that creates a log entry when a model instance is changed and saved to the database.
Direct use is discouraged, connect your model through :py:func:`auditlog.registry.register` instead.
"""
- if instance.pk is not None:
- try:
- old = sender.objects.get(pk=instance.pk)
- except sender.DoesNotExist:
- pass
- else:
- new = instance
- update_fields = kwargs.get("update_fields", None)
- changes = model_instance_diff(old, new, fields_to_check=update_fields)
-
- # Log an entry only if there are changes
- if changes:
- LogEntry.objects.log_create(
- instance,
- action=LogEntry.Action.UPDATE,
- changes=json.dumps(changes),
- )
+ if not instance._state.adding:
+ update_fields = kwargs.get("update_fields", None)
+ old = sender.objects.filter(pk=instance.pk).first()
+ _create_log_entry(
+ action=LogEntry.Action.UPDATE,
+ instance=instance,
+ sender=sender,
+ diff_old=old,
+ diff_new=instance,
+ fields_to_check=update_fields,
+ )
+@check_disable
def log_delete(sender, instance, **kwargs):
"""
Signal receiver that creates a log entry when a model instance is deleted from the database.
@@ -52,18 +74,71 @@ def log_delete(sender, instance, **kwargs):
Direct use is discouraged, connect your model through :py:func:`auditlog.registry.register` instead.
"""
if instance.pk is not None:
- changes = model_instance_diff(instance, None)
-
- LogEntry.objects.log_create(
- instance,
+ _create_log_entry(
action=LogEntry.Action.DELETE,
- changes=json.dumps(changes),
+ instance=instance,
+ sender=sender,
+ diff_old=instance,
+ diff_new=None,
+ )
+
+
+def log_access(sender, instance, **kwargs):
+ """
+ Signal receiver that creates a log entry when a model instance is accessed in a AccessLogDetailView.
+
+ Direct use is discouraged, connect your model through :py:func:`auditlog.registry.register` instead.
+ """
+ if instance.pk is not None:
+ _create_log_entry(
+ action=LogEntry.Action.ACCESS,
+ instance=instance,
+ sender=sender,
+ diff_old=None,
+ diff_new=None,
+ force_log=True,
+ )
+
+
+def _create_log_entry(
+ action, instance, sender, diff_old, diff_new, fields_to_check=None, force_log=False
+):
+ pre_log_results = pre_log.send(
+ sender,
+ instance=instance,
+ action=action,
+ )
+ error = None
+ try:
+ changes = model_instance_diff(
+ diff_old, diff_new, fields_to_check=fields_to_check
+ )
+
+ if force_log or changes:
+ LogEntry.objects.log_create(
+ instance,
+ action=action,
+ changes=changes,
+ force_log=force_log,
+ )
+ except BaseException as e:
+ error = e
+ finally:
+ post_log.send(
+ sender,
+ instance=instance,
+ action=action,
+ error=error,
+ pre_log_results=pre_log_results,
)
+ if error:
+ raise error
def make_log_m2m_changes(field_name):
"""Return a handler for m2m_changed with field_name enclosed."""
+ @check_disable
def log_m2m_changes(signal, action, **kwargs):
"""Handle m2m_changed and call LogEntry.objects.log_m2m_changes as needed."""
if action not in ["post_add", "post_clear", "post_remove"]:
diff --git a/auditlog/registry.py b/auditlog/registry.py
index cc05ba8b..da299793 100644
--- a/auditlog/registry.py
+++ b/auditlog/registry.py
@@ -1,19 +1,10 @@
import copy
from collections import defaultdict
-from typing import (
- Any,
- Callable,
- Collection,
- Dict,
- Iterable,
- List,
- Optional,
- Tuple,
- Union,
-)
+from collections.abc import Collection, Iterable
+from typing import Any, Callable, Optional, Union
from django.apps import apps
-from django.db.models import Model
+from django.db.models import ManyToManyField, Model
from django.db.models.base import ModelBase
from django.db.models.signals import (
ModelSignal,
@@ -24,8 +15,13 @@
)
from auditlog.conf import settings
+from auditlog.signals import accessed
+
+DispatchUID = tuple[int, int, int]
-DispatchUID = Tuple[int, int, int]
+
+class AuditLogRegistrationError(Exception):
+ pass
class AuditlogModelRegistry:
@@ -40,10 +36,11 @@ def __init__(
create: bool = True,
update: bool = True,
delete: bool = True,
+ access: bool = True,
m2m: bool = True,
- custom: Optional[Dict[ModelSignal, Callable]] = None,
+ custom: Optional[dict[ModelSignal, Callable]] = None,
):
- from auditlog.receivers import log_create, log_delete, log_update
+ from auditlog.receivers import log_access, log_create, log_delete, log_update
self._registry = {}
self._signals = {}
@@ -55,6 +52,8 @@ def __init__(
self._signals[pre_save] = log_update
if delete:
self._signals[post_delete] = log_delete
+ if access:
+ self._signals[accessed] = log_access
self._m2m = m2m
if custom is not None:
@@ -63,11 +62,14 @@ def __init__(
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,
+ 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,
m2m_fields: Optional[Collection[str]] = None,
+ serialize_data: bool = False,
+ serialize_kwargs: Optional[dict[str, Any]] = None,
+ serialize_auditlog_fields_only: bool = False,
):
"""
Register a model with auditlog. Auditlog will then track mutations on this model's instances.
@@ -78,7 +80,9 @@ def register(
:param mapping_fields: Mapping from field names to strings in diff.
:param mask_fields: The fields to mask for sensitive info.
:param m2m_fields: The fields to handle as many to many.
-
+ :param serialize_data: Option to include a dictionary of the objects state in the auditlog.
+ :param serialize_kwargs: Optional kwargs to pass to Django serializer
+ :param serialize_auditlog_fields_only: Only fields being considered in changes will be serialized.
"""
if include_fields is None:
@@ -91,6 +95,17 @@ def register(
mask_fields = []
if m2m_fields is None:
m2m_fields = set()
+ if serialize_kwargs is None:
+ serialize_kwargs = {}
+
+ if (serialize_kwargs or serialize_auditlog_fields_only) and not serialize_data:
+ raise AuditLogRegistrationError(
+ "Serializer options were given but the 'serialize_data' option is not "
+ "set. Did you forget to set serialized_data to True?"
+ )
+
+ for fld in settings.AUDITLOG_EXCLUDE_TRACKING_FIELDS:
+ exclude_fields.append(fld)
def registrar(cls):
"""Register models for a given class."""
@@ -103,6 +118,9 @@ def registrar(cls):
"mapping_fields": mapping_fields,
"mask_fields": mask_fields,
"m2m_fields": m2m_fields,
+ "serialize_data": serialize_data,
+ "serialize_kwargs": serialize_kwargs,
+ "serialize_auditlog_fields_only": serialize_auditlog_fields_only,
}
self._connect_signals(cls)
@@ -117,7 +135,7 @@ def registrar(cls):
return lambda cls: registrar(cls)
else:
# Otherwise, just register the model.
- registrar(model)
+ return registrar(model)
def contains(self, model: ModelBase) -> bool:
"""
@@ -142,7 +160,7 @@ def unregister(self, model: ModelBase) -> None:
else:
self._disconnect_signals(model)
- def get_models(self) -> List[ModelBase]:
+ def get_models(self) -> list[ModelBase]:
"""Get a list of all registered models."""
return list(self._registry.keys())
@@ -154,6 +172,15 @@ def get_model_fields(self, model: ModelBase):
"mask_fields": list(self._registry[model]["mask_fields"]),
}
+ def get_serialize_options(self, model: ModelBase):
+ return {
+ "serialize_data": bool(self._registry[model]["serialize_data"]),
+ "serialize_kwargs": dict(self._registry[model]["serialize_kwargs"]),
+ "serialize_auditlog_fields_only": bool(
+ self._registry[model]["serialize_auditlog_fields_only"]
+ ),
+ }
+
def _connect_signals(self, model):
"""
Connect signals for the model.
@@ -200,7 +227,7 @@ def _dispatch_uid(self, signal, receiver) -> DispatchUID:
"""Generate a dispatch_uid which is unique for a combination of self, signal, and receiver."""
return id(self), id(signal), id(receiver)
- def _get_model_classes(self, app_model: str) -> List[ModelBase]:
+ def _get_model_classes(self, app_model: str) -> list[ModelBase]:
try:
try:
app_label, model_name = app_model.split(".")
@@ -212,15 +239,16 @@ def _get_model_classes(self, app_model: str) -> List[ModelBase]:
def _get_exclude_models(
self, exclude_tracking_models: Iterable[str]
- ) -> List[ModelBase]:
+ ) -> list[ModelBase]:
exclude_models = [
model
- for app_model in exclude_tracking_models + self.DEFAULT_EXCLUDE_MODELS
+ for app_model in tuple(exclude_tracking_models)
+ + self.DEFAULT_EXCLUDE_MODELS
for model in self._get_model_classes(app_model)
]
return exclude_models
- def _register_models(self, models: Iterable[Union[str, Dict[str, Any]]]) -> None:
+ def _register_models(self, models: Iterable[Union[str, dict[str, Any]]]) -> None:
models = copy.deepcopy(models)
for model in models:
if isinstance(model, str):
@@ -228,7 +256,13 @@ def _register_models(self, models: Iterable[Union[str, Dict[str, Any]]]) -> None
self.unregister(model_class)
self.register(model_class)
elif isinstance(model, dict):
- model["model"] = self._get_model_classes(model["model"])[0]
+ appmodel = self._get_model_classes(model["model"])
+ if not appmodel:
+ raise AuditLogRegistrationError(
+ f"An error was encountered while registering model '{model['model']}' - "
+ "make sure the app is registered correctly."
+ )
+ model["model"] = appmodel[0]
self.unregister(model["model"])
self.register(**model)
@@ -238,7 +272,8 @@ def register_from_settings(self):
"""
if not isinstance(settings.AUDITLOG_INCLUDE_ALL_MODELS, bool):
raise TypeError("Setting 'AUDITLOG_INCLUDE_ALL_MODELS' must be a boolean")
-
+ if not isinstance(settings.AUDITLOG_DISABLE_ON_RAW_SAVE, bool):
+ raise TypeError("Setting 'AUDITLOG_DISABLE_ON_RAW_SAVE' must be a boolean")
if not isinstance(settings.AUDITLOG_EXCLUDE_TRACKING_MODELS, (list, tuple)):
raise TypeError(
"Setting 'AUDITLOG_EXCLUDE_TRACKING_MODELS' must be a list or tuple"
@@ -253,11 +288,25 @@ def register_from_settings(self):
"setting 'AUDITLOG_INCLUDE_ALL_MODELS' must set to 'True'"
)
+ if (
+ settings.AUDITLOG_EXCLUDE_TRACKING_FIELDS
+ and not settings.AUDITLOG_INCLUDE_ALL_MODELS
+ ):
+ raise ValueError(
+ "In order to use 'AUDITLOG_EXCLUDE_TRACKING_FIELDS', "
+ "setting 'AUDITLOG_INCLUDE_ALL_MODELS' must be set to 'True'"
+ )
+
if not isinstance(settings.AUDITLOG_INCLUDE_TRACKING_MODELS, (list, tuple)):
raise TypeError(
"Setting 'AUDITLOG_INCLUDE_TRACKING_MODELS' must be a list or tuple"
)
+ if not isinstance(settings.AUDITLOG_EXCLUDE_TRACKING_FIELDS, (list, tuple)):
+ raise TypeError(
+ "Setting 'AUDITLOG_EXCLUDE_TRACKING_FIELDS' must be a list or tuple"
+ )
+
for item in settings.AUDITLOG_INCLUDE_TRACKING_MODELS:
if not isinstance(item, (str, dict)):
raise TypeError(
@@ -279,12 +328,28 @@ def register_from_settings(self):
exclude_models = self._get_exclude_models(
settings.AUDITLOG_EXCLUDE_TRACKING_MODELS
)
- models = apps.get_models()
- for model in models:
+ for model in apps.get_models(include_auto_created=True):
if model in exclude_models:
continue
- self.register(model)
+
+ meta = model._meta
+ if not meta.managed:
+ continue
+
+ m2m_fields = [
+ m.name for m in meta.get_fields() if isinstance(m, ManyToManyField)
+ ]
+
+ exclude_fields = [
+ i.related_name
+ for i in meta.related_objects
+ if i.related_name and not i.related_model._meta.managed
+ ]
+
+ self.register(
+ model=model, m2m_fields=m2m_fields, exclude_fields=exclude_fields
+ )
self._register_models(settings.AUDITLOG_INCLUDE_TRACKING_MODELS)
diff --git a/auditlog/signals.py b/auditlog/signals.py
new file mode 100644
index 00000000..aec291a6
--- /dev/null
+++ b/auditlog/signals.py
@@ -0,0 +1,53 @@
+import django.dispatch
+
+accessed = django.dispatch.Signal()
+
+
+pre_log = django.dispatch.Signal()
+"""
+Whenever an audit log entry is written, this signal
+is sent before writing the log.
+Keyword arguments sent with this signal:
+
+:param class sender:
+ The model class that's being audited.
+
+:param Any instance:
+ The actual instance that's being audited.
+
+:param Action action:
+ The action on the model resulting in an
+ audit log entry. Type: :class:`auditlog.models.LogEntry.Action`
+
+The receivers' return values are sent to any :func:`post_log`
+signal receivers.
+"""
+
+post_log = django.dispatch.Signal()
+"""
+Whenever an audit log entry is written, this signal
+is sent after writing the log.
+Keyword arguments sent with this signal:
+
+:param class sender:
+ The model class that's being audited.
+
+:param Any instance:
+ The actual instance that's being audited.
+
+:param Action action:
+ The action on the model resulting in an
+ audit log entry. Type: :class:`auditlog.models.LogEntry.Action`
+
+:param Optional[Exception] error:
+ The error, if one occurred while saving the audit log entry. ``None``,
+ otherwise
+
+:param List[Tuple[method,Any]] pre_log_results:
+ List of tuple pairs ``[(pre_log_receiver, pre_log_response)]``, where
+ ``pre_log_receiver`` is the receiver method, and ``pre_log_response`` is the
+ corresponding response of that method. If there are no :const:`pre_log` receivers,
+ then the list will be empty. ``pre_log_receiver`` is guaranteed to be
+ non-null, but ``pre_log_response`` may be ``None``. This depends on the corresponding
+ ``pre_log_receiver``'s return value.
+"""
diff --git a/auditlog_tests/fixtures/custom_get_cid.py b/auditlog_tests/fixtures/custom_get_cid.py
new file mode 100644
index 00000000..a8e70454
--- /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/fixtures/m2m_test_fixture.json b/auditlog_tests/fixtures/m2m_test_fixture.json
new file mode 100644
index 00000000..c5c5d9e3
--- /dev/null
+++ b/auditlog_tests/fixtures/m2m_test_fixture.json
@@ -0,0 +1,15 @@
+[
+ {
+ "model": "auditlog_tests.manyrelatedmodel",
+ "pk": 1,
+ "fields": {
+ "recursive": [1],
+ "related": [1]
+ }
+ },
+ {
+ "model": "auditlog_tests.manyrelatedothermodel",
+ "pk": 1,
+ "fields": {}
+ }
+]
\ No newline at end of file
diff --git a/auditlog_tests/models.py b/auditlog_tests/models.py
index 0e6af497..3e0678b5 100644
--- a/auditlog_tests/models.py
+++ b/auditlog_tests/models.py
@@ -1,6 +1,7 @@
import uuid
from django.contrib.postgres.fields import ArrayField
+from django.core.serializers.json import DjangoJSONEncoder
from django.db import models
from auditlog.models import AuditlogHistoryField
@@ -20,7 +21,7 @@ class SimpleModel(models.Model):
integer = models.IntegerField(blank=True, null=True)
datetime = models.DateTimeField(auto_now=True)
- history = AuditlogHistoryField()
+ history = AuditlogHistoryField(delete_related=True)
class AltPrimaryKeyModel(models.Model):
@@ -35,7 +36,7 @@ class AltPrimaryKeyModel(models.Model):
integer = models.IntegerField(blank=True, null=True)
datetime = models.DateTimeField(auto_now=True)
- history = AuditlogHistoryField(pk_indexable=False)
+ history = AuditlogHistoryField(delete_related=True, pk_indexable=False)
class UUIDPrimaryKeyModel(models.Model):
@@ -50,7 +51,7 @@ class UUIDPrimaryKeyModel(models.Model):
integer = models.IntegerField(blank=True, null=True)
datetime = models.DateTimeField(auto_now=True)
- history = AuditlogHistoryField(pk_indexable=False)
+ history = AuditlogHistoryField(delete_related=True, pk_indexable=False)
class ProxyModel(SimpleModel):
@@ -80,7 +81,7 @@ class RelatedModel(RelatedModelParent):
to="SimpleModel", on_delete=models.CASCADE, related_name="reverse_one_to_one"
)
- history = AuditlogHistoryField()
+ history = AuditlogHistoryField(delete_related=True)
class ManyRelatedModel(models.Model):
@@ -91,7 +92,7 @@ class ManyRelatedModel(models.Model):
recursive = models.ManyToManyField("self")
related = models.ManyToManyField("ManyRelatedOtherModel", related_name="related")
- history = AuditlogHistoryField()
+ history = AuditlogHistoryField(delete_related=True)
def get_additional_data(self):
related = self.related.first()
@@ -103,7 +104,7 @@ class ManyRelatedOtherModel(models.Model):
A model related to ManyRelatedModel as many-to-many.
"""
- history = AuditlogHistoryField()
+ history = AuditlogHistoryField(delete_related=True)
@auditlog.register(include_fields=["label"])
@@ -115,7 +116,7 @@ class SimpleIncludeModel(models.Model):
label = models.CharField(max_length=100)
text = models.TextField(blank=True)
- history = AuditlogHistoryField()
+ history = AuditlogHistoryField(delete_related=True)
class SimpleExcludeModel(models.Model):
@@ -126,7 +127,7 @@ class SimpleExcludeModel(models.Model):
label = models.CharField(max_length=100)
text = models.TextField(blank=True)
- history = AuditlogHistoryField()
+ history = AuditlogHistoryField(delete_related=True)
class SimpleMappingModel(models.Model):
@@ -138,7 +139,7 @@ class SimpleMappingModel(models.Model):
vtxt = models.CharField(verbose_name="Version", max_length=100)
not_mapped = models.CharField(max_length=100)
- history = AuditlogHistoryField()
+ history = AuditlogHistoryField(delete_related=True)
@auditlog.register(mask_fields=["address"])
@@ -150,7 +151,7 @@ class SimpleMaskedModel(models.Model):
address = models.CharField(max_length=100)
text = models.TextField()
- history = AuditlogHistoryField()
+ history = AuditlogHistoryField(delete_related=True)
class AdditionalDataIncludedModel(models.Model):
@@ -163,7 +164,7 @@ class AdditionalDataIncludedModel(models.Model):
text = models.TextField(blank=True)
related = models.ForeignKey(to=SimpleModel, on_delete=models.CASCADE)
- history = AuditlogHistoryField()
+ history = AuditlogHistoryField(delete_related=True)
def get_additional_data(self):
"""
@@ -190,7 +191,7 @@ class DateTimeFieldModel(models.Model):
time = models.TimeField()
naive_dt = models.DateTimeField(null=True, blank=True)
- history = AuditlogHistoryField()
+ history = AuditlogHistoryField(delete_related=True)
class ChoicesFieldModel(models.Model):
@@ -212,7 +213,7 @@ class ChoicesFieldModel(models.Model):
status = models.CharField(max_length=1, choices=STATUS_CHOICES)
multiplechoice = models.CharField(max_length=255, choices=STATUS_CHOICES)
- history = AuditlogHistoryField()
+ history = AuditlogHistoryField(delete_related=True)
class CharfieldTextfieldModel(models.Model):
@@ -225,7 +226,7 @@ class CharfieldTextfieldModel(models.Model):
longchar = models.CharField(max_length=255)
longtextfield = models.TextField()
- history = AuditlogHistoryField()
+ history = AuditlogHistoryField(delete_related=True)
class PostgresArrayFieldModel(models.Model):
@@ -247,7 +248,7 @@ class PostgresArrayFieldModel(models.Model):
models.CharField(max_length=1, choices=STATUS_CHOICES), size=3
)
- history = AuditlogHistoryField()
+ history = AuditlogHistoryField(delete_related=True)
class NoDeleteHistoryModel(models.Model):
@@ -257,11 +258,72 @@ class NoDeleteHistoryModel(models.Model):
class JSONModel(models.Model):
- json = models.JSONField(default=dict)
+ json = models.JSONField(default=dict, encoder=DjangoJSONEncoder)
+
+ history = AuditlogHistoryField(delete_related=False)
+
+
+class SerializeThisModel(models.Model):
+ label = models.CharField(max_length=24, unique=True)
+ timestamp = models.DateTimeField()
+ nullable = models.IntegerField(null=True)
+ nested = models.JSONField()
+ mask_me = models.CharField(max_length=255, null=True)
+ code = models.UUIDField(null=True)
+ date = models.DateField(null=True)
+
+ history = AuditlogHistoryField(delete_related=False)
+
+ def natural_key(self):
+ return self.label
+
+
+class SerializeOnlySomeOfThisModel(models.Model):
+ this = models.CharField(max_length=24)
+ not_this = models.CharField(max_length=24)
+
+ history = AuditlogHistoryField(delete_related=False)
+
+
+class SerializePrimaryKeyRelatedModel(models.Model):
+ serialize_this = models.ForeignKey(to=SerializeThisModel, on_delete=models.CASCADE)
+ subheading = models.CharField(max_length=255)
+ value = models.IntegerField()
+
+ history = AuditlogHistoryField(delete_related=False)
+
+
+class SerializeNaturalKeyRelatedModel(models.Model):
+ serialize_this = models.ForeignKey(to=SerializeThisModel, on_delete=models.CASCADE)
+ subheading = models.CharField(max_length=255)
+ value = models.IntegerField()
history = AuditlogHistoryField(delete_related=False)
+class SimpleNonManagedModel(models.Model):
+ """
+ A simple model with no special things going on.
+ """
+
+ text = models.TextField(blank=True)
+ boolean = models.BooleanField(default=False)
+ integer = models.IntegerField(blank=True, null=True)
+ datetime = models.DateTimeField(auto_now=True)
+
+ history = AuditlogHistoryField()
+
+ def __str__(self):
+ return self.text
+
+ class Meta:
+ managed = False
+
+
+class AutoManyRelatedModel(models.Model):
+ related = models.ManyToManyField(SimpleModel)
+
+
auditlog.register(AltPrimaryKeyModel)
auditlog.register(UUIDPrimaryKeyModel)
auditlog.register(ProxyModel)
@@ -278,3 +340,20 @@ class JSONModel(models.Model):
auditlog.register(PostgresArrayFieldModel)
auditlog.register(NoDeleteHistoryModel)
auditlog.register(JSONModel)
+auditlog.register(
+ SerializeThisModel,
+ serialize_data=True,
+ mask_fields=["mask_me"],
+)
+auditlog.register(
+ SerializeOnlySomeOfThisModel,
+ serialize_data=True,
+ serialize_auditlog_fields_only=True,
+ exclude_fields=["not_this"],
+)
+auditlog.register(SerializePrimaryKeyRelatedModel, serialize_data=True)
+auditlog.register(
+ SerializeNaturalKeyRelatedModel,
+ serialize_data=True,
+ serialize_kwargs={"use_natural_foreign_keys": True},
+)
diff --git a/auditlog_tests/templates/simplemodel_detail.html b/auditlog_tests/templates/simplemodel_detail.html
new file mode 100644
index 00000000..e69de29b
diff --git a/auditlog_tests/test_settings.py b/auditlog_tests/test_settings.py
index 6eda1cb8..e89d659e 100644
--- a/auditlog_tests/test_settings.py
+++ b/auditlog_tests/test_settings.py
@@ -1,6 +1,7 @@
"""
Settings file for the Auditlog test suite.
"""
+
import os
DEBUG = True
@@ -14,6 +15,7 @@
"django.contrib.sessions",
"django.contrib.admin",
"django.contrib.staticfiles",
+ "django.contrib.postgres",
"auditlog",
"auditlog_tests",
]
diff --git a/auditlog_tests/test_two_step_json_migration.py b/auditlog_tests/test_two_step_json_migration.py
new file mode 100644
index 00000000..7df115e8
--- /dev/null
+++ b/auditlog_tests/test_two_step_json_migration.py
@@ -0,0 +1,189 @@
+import json
+from io import StringIO
+from unittest.mock import patch
+
+from django.core.management import CommandError, call_command
+from django.test import TestCase, override_settings
+
+from auditlog.models import LogEntry
+from auditlog_tests.models import SimpleModel
+
+
+class TwoStepMigrationTest(TestCase):
+ def test_use_text_changes_first(self):
+ text_obj = '{"field": "changes_text"}'
+ json_obj = {"field": "changes"}
+ _params = [
+ (True, None, text_obj, {"field": "changes_text"}),
+ (True, json_obj, text_obj, json_obj),
+ (True, None, "not json", {}),
+ (False, json_obj, text_obj, json_obj),
+ ]
+
+ for setting_value, changes_value, changes_text_value, expected in _params:
+ with self.subTest():
+ entry = LogEntry(changes=changes_value, changes_text=changes_text_value)
+ with self.settings(
+ AUDITLOG_USE_TEXT_CHANGES_IF_JSON_IS_NOT_PRESENT=setting_value
+ ):
+ from auditlog import models
+
+ changes_dict = models._changes_func()(entry)
+ self.assertEqual(changes_dict, expected)
+
+
+class AuditlogMigrateJsonTest(TestCase):
+ def make_logentry(self):
+ model = SimpleModel.objects.create(text="I am a simple model.")
+ log_entry: LogEntry = model.history.first()
+ log_entry.changes_text = json.dumps(log_entry.changes)
+ log_entry.changes = None
+ log_entry.save()
+ return log_entry
+
+ def call_command(self, *args, **kwargs):
+ outbuf = StringIO()
+ errbuf = StringIO()
+ args = ("--no-color",) + args
+ call_command(
+ "auditlogmigratejson", *args, stdout=outbuf, stderr=errbuf, **kwargs
+ )
+ return outbuf.getvalue().strip(), errbuf.getvalue().strip()
+
+ def test_nothing_to_migrate(self):
+ outbuf, errbuf = self.call_command()
+
+ msg = "All records have been migrated."
+ self.assertEqual(outbuf, msg)
+
+ @override_settings(AUDITLOG_USE_TEXT_CHANGES_IF_JSON_IS_NOT_PRESENT=True)
+ def test_nothing_to_migrate_with_conf_true(self):
+ outbuf, errbuf = self.call_command()
+
+ msg = (
+ "All records have been migrated.\n"
+ "You can now set AUDITLOG_USE_TEXT_CHANGES_IF_JSON_IS_NOT_PRESENT to False."
+ )
+
+ self.assertEqual(outbuf, msg)
+
+ def test_check(self):
+ # Arrange
+ log_entry = self.make_logentry()
+
+ # Act
+ outbuf, errbuf = self.call_command("--check")
+ log_entry.refresh_from_db()
+
+ # Assert
+ self.assertEqual("There are 1 records that needs migration.", outbuf)
+ self.assertEqual("", errbuf)
+ self.assertIsNone(log_entry.changes)
+
+ def test_using_django(self):
+ # Arrange
+ log_entry = self.make_logentry()
+
+ # Act
+ outbuf, errbuf = self.call_command("-b=0")
+ log_entry.refresh_from_db()
+
+ # Assert
+ self.assertEqual(errbuf, "")
+ self.assertIsNotNone(log_entry.changes)
+
+ def test_using_django_batched(self):
+ # Arrange
+ log_entry_1 = self.make_logentry()
+ log_entry_2 = self.make_logentry()
+
+ # Act
+ outbuf, errbuf = self.call_command("-b=1")
+ log_entry_1.refresh_from_db()
+ log_entry_2.refresh_from_db()
+
+ # Assert
+ self.assertEqual(errbuf, "")
+ self.assertIsNotNone(log_entry_1.changes)
+ self.assertIsNotNone(log_entry_2.changes)
+
+ def test_using_django_batched_call_count(self):
+ """
+ This is split into a different test because I couldn't figure out how to properly patch bulk_update.
+ For some reason, then I
+ """
+ # Arrange
+ self.make_logentry()
+ self.make_logentry()
+
+ # Act
+ with patch("auditlog.models.LogEntry.objects.bulk_update") as bulk_update:
+ outbuf, errbuf = self.call_command("-b=1")
+ call_count = bulk_update.call_count
+
+ # Assert
+ self.assertEqual(call_count, 2)
+
+ def test_native_postgres(self):
+ # Arrange
+ log_entry = self.make_logentry()
+
+ # Act
+ outbuf, errbuf = self.call_command("-d=postgres")
+ log_entry.refresh_from_db()
+
+ # Assert
+ self.assertEqual(errbuf, "")
+ self.assertIsNotNone(log_entry.changes)
+
+ def test_native_postgres_changes_not_overwritten(self):
+ # Arrange
+ log_entry = self.make_logentry()
+ log_entry.changes = original_changes = {"key": "value"}
+ log_entry.changes_text = '{"key": "new value"}'
+ log_entry.save()
+
+ # Act
+ outbuf, errbuf = self.call_command("-d=postgres")
+ log_entry.refresh_from_db()
+
+ # Assert
+ self.assertEqual(errbuf, "")
+ self.assertEqual(log_entry.changes, original_changes)
+
+ def test_native_unsupported(self):
+ # Arrange
+ log_entry = self.make_logentry()
+ msg = (
+ "Migrating the records using oracle is not implemented. "
+ "Run this management command without passing a -d/--database argument."
+ )
+
+ # Act
+ with self.assertRaises(CommandError) as cm:
+ self.call_command("-d=oracle")
+ log_entry.refresh_from_db()
+
+ # Assert
+ self.assertEqual(msg, cm.exception.args[0])
+ self.assertIsNone(log_entry.changes)
+
+ def test_using_django_with_error(self):
+ # Arrange
+ log_entry = self.make_logentry()
+ log_entry.changes_text = "not json"
+ log_entry.save()
+
+ # Act
+ outbuf, errbuf = self.call_command()
+ log_entry.refresh_from_db()
+
+ # Assert
+ msg = (
+ f"ValueError was raised while converting the logs with these ids into json."
+ f"They where not be included in this migration batch."
+ f"\n"
+ f"{[log_entry.id]}"
+ )
+ self.assertEqual(msg, errbuf)
+ self.assertIsNone(log_entry.changes)
diff --git a/auditlog_tests/tests.py b/auditlog_tests/tests.py
index 7f1c145a..c96857ad 100644
--- a/auditlog_tests/tests.py
+++ b/auditlog_tests/tests.py
@@ -1,29 +1,44 @@
import datetime
import itertools
import json
+import random
import warnings
+from datetime import timezone
from unittest import mock
+from unittest.mock import patch
+import freezegun
from dateutil.tz import gettz
+from django import VERSION as DJANGO_VERSION
from django.apps import apps
from django.conf import settings
from django.contrib.admin.sites import AdminSite
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.db import models
+from django.db.models.functions import Now
from django.db.models.signals import pre_save
from django.test import RequestFactory, TestCase, override_settings
-from django.utils import dateformat, formats, timezone
+from django.urls import resolve, reverse
+from django.utils import dateformat, formats
+from django.utils import timezone as django_timezone
+from django.utils.encoding import smart_str
from auditlog.admin import LogEntryAdmin
-from auditlog.context import set_actor
+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, auditlog
+from auditlog.registry import AuditlogModelRegistry, AuditLogRegistrationError, auditlog
+from auditlog.signals import post_log, pre_log
+from auditlog_tests.fixtures.custom_get_cid import get_cid as custom_get_cid
from auditlog_tests.models import (
AdditionalDataIncludedModel,
AltPrimaryKeyModel,
+ AutoManyRelatedModel,
CharfieldTextfieldModel,
ChoicesFieldModel,
DateTimeFieldModel,
@@ -34,11 +49,16 @@
PostgresArrayFieldModel,
ProxyModel,
RelatedModel,
+ SerializeNaturalKeyRelatedModel,
+ SerializeOnlySomeOfThisModel,
+ SerializePrimaryKeyRelatedModel,
+ SerializeThisModel,
SimpleExcludeModel,
SimpleIncludeModel,
SimpleMappingModel,
SimpleMaskedModel,
SimpleModel,
+ SimpleNonManagedModel,
UUIDPrimaryKeyModel,
)
@@ -91,9 +111,9 @@ def update(self, obj):
obj.save()
def check_update_log_entry(self, obj, history):
- self.assertJSONEqual(
+ self.assertDictEqual(
history.changes,
- '{"boolean": ["False", "True"]}',
+ {"boolean": ["False", "True"]},
msg="The change is correctly logged",
)
@@ -106,9 +126,9 @@ def test_update_specific_field_supplied_via_save_method(self):
obj.save(update_fields=["boolean"])
# This implicitly asserts there is only one UPDATE change since the `.get` would fail otherwise.
- self.assertJSONEqual(
+ self.assertDictEqual(
obj.history.get(action=LogEntry.Action.UPDATE).changes,
- '{"boolean": ["False", "True"]}',
+ {"boolean": ["False", "True"]},
msg=(
"Object modifications that are not saved to DB are not logged "
"when using the `update_fields`."
@@ -139,9 +159,9 @@ def test_django_update_fields_edge_cases(self):
obj.integer = 1
obj.boolean = True
obj.save(update_fields=None)
- self.assertJSONEqual(
+ self.assertDictEqual(
obj.history.get(action=LogEntry.Action.UPDATE).changes,
- '{"boolean": ["False", "True"], "integer": ["None", "1"]}',
+ {"boolean": ["False", "True"], "integer": ["None", "1"]},
msg="The 2 fields changed are correctly logged",
)
@@ -191,6 +211,24 @@ def test_create_log_to_object_from_other_database(self):
log_entry._state.db, "default", msg=msg
) # must be created in default database
+ def test_default_timestamp(self):
+ start = django_timezone.now()
+ self.test_recreate()
+ end = django_timezone.now()
+ history = self.obj.history.latest()
+ self.assertTrue(start <= history.timestamp <= end)
+
+ def test_manual_timestamp(self):
+ timestamp = datetime.datetime(1999, 12, 31, 23, 59, 59, tzinfo=timezone.utc)
+ LogEntry.objects.log_create(
+ instance=self.obj,
+ timestamp=timestamp,
+ changes="foo bar",
+ action=LogEntry.Action.UPDATE,
+ )
+ history = self.obj.history.filter(timestamp=timestamp, changes="foo bar")
+ self.assertTrue(history.exists())
+
class NoActorMixin:
def check_create_log_entry(self, obj, log_entry):
@@ -368,6 +406,38 @@ def test_additional_data(self):
log_entry.additional_data, {"related_model_id": self.related.id}
)
+ def test_changes(self):
+ self.obj.related.add(self.related)
+ log_entry = self.obj.history.first()
+ self.assertEqual(
+ log_entry.changes,
+ {
+ "related": {
+ "type": "m2m",
+ "operation": "add",
+ "objects": [smart_str(self.related)],
+ }
+ },
+ )
+
+ def test_adding_existing_related_obj(self):
+ self.obj.related.add(self.related)
+ log_entry = self.obj.history.first()
+ self.assertEqual(
+ log_entry.changes,
+ {
+ "related": {
+ "type": "m2m",
+ "operation": "add",
+ "objects": [smart_str(self.related)],
+ }
+ },
+ )
+ # Add same related obj again.
+ self.obj.related.add(self.related)
+ latest_log_entry = self.obj.history.first()
+ self.assertEqual(log_entry.id, latest_log_entry.id)
+
class MiddlewareTest(TestCase):
"""
@@ -401,7 +471,7 @@ def test_request_anonymous(self):
request = self.factory.get("/")
request.user = AnonymousUser()
- self.get_response_mock.side_effect = self.side_effect(self.assert_no_listeners)
+ self.get_response_mock.side_effect = self.side_effect(self.assert_has_listeners)
response = self.middleware(request)
@@ -436,6 +506,115 @@ def test_exception(self):
self.assert_no_listeners()
+ def test_init_middleware(self):
+ with override_settings(AUDITLOG_DISABLE_REMOTE_ADDR="str"):
+ with self.assertRaisesMessage(
+ TypeError, "Setting 'AUDITLOG_DISABLE_REMOTE_ADDR' must be a boolean"
+ ):
+ AuditlogMiddleware()
+
+ def test_disable_remote_addr(self):
+ with override_settings(AUDITLOG_DISABLE_REMOTE_ADDR=True):
+ headers = {"HTTP_X_FORWARDED_FOR": "127.0.0.2"}
+ request = self.factory.get("/", **headers)
+ remote_addr = self.middleware._get_remote_addr(request)
+ self.assertIsNone(remote_addr)
+
+ def test_get_remote_addr(self):
+ tests = [ # (headers, expected_remote_addr)
+ ({}, "127.0.0.1"),
+ ({"HTTP_X_FORWARDED_FOR": "127.0.0.2"}, "127.0.0.2"),
+ ({"HTTP_X_FORWARDED_FOR": "127.0.0.3:1234"}, "127.0.0.3"),
+ ({"HTTP_X_FORWARDED_FOR": "2606:4700:4700::1111"}, "2606:4700:4700::1111"),
+ (
+ {"HTTP_X_FORWARDED_FOR": "[2606:4700:4700::1001]:1234"},
+ "2606:4700:4700::1001",
+ ),
+ ]
+ for headers, expected_remote_addr in tests:
+ with self.subTest(headers=headers):
+ request = self.factory.get("/", **headers)
+ self.assertEqual(
+ self.middleware._get_remote_addr(request), expected_remote_addr
+ )
+
+ def test_get_remote_port(self):
+ headers = {
+ "HTTP_X_FORWARDED_PORT": "12345",
+ }
+ request = self.factory.get("/", **headers)
+ self.assertEqual(self.middleware._get_remote_port(request), 12345)
+
+ 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)
+
+ def test_set_actor_anonymous_request(self):
+ """
+ The remote address will be set even when there is no actor
+ """
+ remote_addr = "123.213.145.99"
+ remote_port = 12345
+ actor = None
+
+ with set_actor(actor=actor, remote_addr=remote_addr, remote_port=remote_port):
+ obj = SimpleModel.objects.create(text="I am not difficult.")
+
+ history = obj.history.get()
+ self.assertEqual(
+ history.remote_addr,
+ remote_addr,
+ msg=f"Remote address is {remote_addr}",
+ )
+ self.assertEqual(
+ history.remote_port,
+ remote_port,
+ msg=f"Remote port is {remote_port}",
+ )
+ self.assertIsNone(history.actor, msg="Actor is `None` for anonymous user")
+
+ def test_get_actor(self):
+ params = [
+ (AnonymousUser(), None, "The user is anonymous so the actor is `None`"),
+ (self.user, self.user, "The use is authenticated so it is the actor"),
+ (None, None, "There is no actor"),
+ ("1234", None, "The value of request.user is not a valid user model"),
+ ]
+ for user, actor, msg in params:
+ with self.subTest(msg):
+ request = self.factory.get("/")
+ request.user = user
+
+ self.assertEqual(self.middleware._get_actor(request), actor)
+
class SimpleIncludeModelTest(TestCase):
"""Log only changes in include_fields"""
@@ -455,9 +634,9 @@ def test_specified_save_fields_are_ignored_if_not_included(self):
obj.text = "Newer text"
obj.save(update_fields=["text", "label"])
- self.assertJSONEqual(
+ self.assertDictEqual(
obj.history.get(action=LogEntry.Action.UPDATE).changes,
- '{"label": ["Initial label", "New label"]}',
+ {"label": ["Initial label", "New label"]},
msg="Only the label was logged, regardless of multiple entries in `update_fields`",
)
@@ -548,7 +727,7 @@ def test_register_mapping_fields(self):
)
-class SimpeMaskedFieldsModelTest(TestCase):
+class SimpleMaskedFieldsModelTest(TestCase):
"""Log masked changes for fields in mask_fields"""
def test_register_mask_fields(self):
@@ -598,8 +777,8 @@ def test_model_with_additional_data(self):
class DateTimeFieldModelTest(TestCase):
"""Tests if DateTimeField changes are recognised correctly"""
- utc_plus_one = timezone.get_fixed_timezone(datetime.timedelta(hours=1))
- now = timezone.now()
+ utc_plus_one = django_timezone.get_fixed_timezone(datetime.timedelta(hours=1))
+ now = django_timezone.now()
def setUp(self):
super().setUp()
@@ -768,7 +947,7 @@ def test_changes_display_dict_datetime(self):
" DATETIME_FORMAT"
),
)
- timestamp = timezone.now()
+ timestamp = django_timezone.now()
dtm.timestamp = timestamp
dtm.save()
localized_timestamp = timestamp.astimezone(gettz(settings.TIME_ZONE))
@@ -892,9 +1071,36 @@ def test_update_naive_dt(self):
dtm.save()
# Change with naive field doesnt raise error
- dtm.naive_dt = timezone.make_naive(timezone.now(), timezone=timezone.utc)
+ dtm.naive_dt = django_timezone.make_naive(
+ django_timezone.now(), timezone=timezone.utc
+ )
+ dtm.save()
+
+ def test_datetime_field_functions_now(self):
+ timestamp = datetime.datetime(2017, 1, 10, 15, 0, tzinfo=timezone.utc)
+ date = datetime.date(2017, 1, 10)
+ time = datetime.time(12, 0)
+
+ dtm = DateTimeFieldModel(
+ label="DateTimeField model",
+ timestamp=timestamp,
+ date=date,
+ time=time,
+ naive_dt=Now(),
+ )
+ dtm.save()
+ dtm.naive_dt = Now()
+ self.assertEqual(dtm.naive_dt, Now())
dtm.save()
+ # Django 6.0+ evaluates expressions during save (django ticket #27222)
+ if DJANGO_VERSION >= (6, 0, 0):
+ with self.subTest("After save Django 6.0+"):
+ self.assertIsInstance(dtm.naive_dt, datetime.datetime)
+ else:
+ with self.subTest("After save Django < 6.0"):
+ self.assertEqual(dtm.naive_dt, Now())
+
class UnregisterTest(TestCase):
def setUp(self):
@@ -936,6 +1142,21 @@ def test_unregister_delete(self):
# Check for log entries
self.assertEqual(LogEntry.objects.count(), 0, msg="There are no log entries")
+ def test_manual_logging(self):
+ obj = self.obj
+ obj.boolean = True
+ obj.save()
+ LogEntry.objects.log_create(
+ instance=obj,
+ action=LogEntry.Action.UPDATE,
+ changes="",
+ )
+ self.assertEqual(
+ obj.history.filter(action=LogEntry.Action.UPDATE).count(),
+ 1,
+ msg="There is one log entry for 'UPDATE'",
+ )
+
class RegisterModelSettingsTest(TestCase):
def setUp(self):
@@ -986,7 +1207,7 @@ def test_register_models_register_app(self):
self.assertTrue(self.test_auditlog.contains(SimpleExcludeModel))
self.assertTrue(self.test_auditlog.contains(ChoicesFieldModel))
- self.assertEqual(len(self.test_auditlog.get_models()), 19)
+ self.assertEqual(len(self.test_auditlog.get_models()), 25)
def test_register_models_register_model_with_attrs(self):
self.test_auditlog._register_models(
@@ -1043,6 +1264,26 @@ def test_register_from_settings_invalid_settings(self):
):
self.test_auditlog.register_from_settings()
+ with override_settings(
+ AUDITLOG_INCLUDE_ALL_MODELS=True,
+ AUDITLOG_EXCLUDE_TRACKING_FIELDS="badvalue",
+ ):
+ with self.assertRaisesMessage(
+ TypeError,
+ "Setting 'AUDITLOG_EXCLUDE_TRACKING_FIELDS' must be a list or tuple",
+ ):
+ self.test_auditlog.register_from_settings()
+
+ with override_settings(
+ AUDITLOG_EXCLUDE_TRACKING_FIELDS=("created", "modified")
+ ):
+ with self.assertRaisesMessage(
+ ValueError,
+ "In order to use 'AUDITLOG_EXCLUDE_TRACKING_FIELDS', "
+ "setting 'AUDITLOG_INCLUDE_ALL_MODELS' must be set to 'True'",
+ ):
+ self.test_auditlog.register_from_settings()
+
with override_settings(AUDITLOG_INCLUDE_TRACKING_MODELS="str"):
with self.assertRaisesMessage(
TypeError,
@@ -1074,11 +1315,57 @@ def test_register_from_settings_invalid_settings(self):
):
self.test_auditlog.register_from_settings()
+ with override_settings(
+ AUDITLOG_INCLUDE_TRACKING_MODELS=({"model": "notanapp.test"},)
+ ):
+ with self.assertRaisesMessage(
+ AuditLogRegistrationError,
+ (
+ "An error was encountered while registering model 'notanapp.test'"
+ " - make sure the app is registered correctly."
+ ),
+ ):
+ self.test_auditlog.register_from_settings()
+
+ with override_settings(AUDITLOG_DISABLE_ON_RAW_SAVE="bad value"):
+ with self.assertRaisesMessage(
+ TypeError, "Setting 'AUDITLOG_DISABLE_ON_RAW_SAVE' must be a boolean"
+ ):
+ self.test_auditlog.register_from_settings()
+
@override_settings(
AUDITLOG_INCLUDE_ALL_MODELS=True,
AUDITLOG_EXCLUDE_TRACKING_MODELS=("auditlog_tests.SimpleExcludeModel",),
)
- def test_register_from_settings_register_all_models_with_exclude_models(self):
+ def test_register_from_settings_register_all_models_with_exclude_models_tuple(self):
+ self.test_auditlog.register_from_settings()
+
+ self.assertFalse(self.test_auditlog.contains(SimpleExcludeModel))
+ self.assertTrue(self.test_auditlog.contains(ChoicesFieldModel))
+
+ @override_settings(
+ AUDITLOG_INCLUDE_ALL_MODELS=True,
+ AUDITLOG_EXCLUDE_TRACKING_FIELDS=("datetime",),
+ )
+ def test_register_from_settings_register_all_models_with_exclude_tracking_fields(
+ self,
+ ):
+ self.test_auditlog.register_from_settings()
+
+ self.assertEqual(
+ self.test_auditlog.get_model_fields(SimpleModel)["exclude_fields"],
+ ["datetime"],
+ )
+ self.assertEqual(
+ self.test_auditlog.get_model_fields(AltPrimaryKeyModel)["exclude_fields"],
+ ["datetime"],
+ )
+
+ @override_settings(
+ AUDITLOG_INCLUDE_ALL_MODELS=True,
+ AUDITLOG_EXCLUDE_TRACKING_MODELS=["auditlog_tests.SimpleExcludeModel"],
+ )
+ def test_register_from_settings_register_all_models_with_exclude_models_list(self):
self.test_auditlog.register_from_settings()
self.assertFalse(self.test_auditlog.contains(SimpleExcludeModel))
@@ -1103,6 +1390,43 @@ def test_register_from_settings_register_models(self):
self.assertEqual(fields["include_fields"], ["label"])
self.assertEqual(fields["exclude_fields"], ["text"])
+ def test_registration_error_if_bad_serialize_params(self):
+ with self.assertRaisesMessage(
+ AuditLogRegistrationError,
+ "Serializer options were given but the 'serialize_data' option is not "
+ "set. Did you forget to set serialized_data to True?",
+ ):
+ register = AuditlogModelRegistry()
+ register.register(
+ SimpleModel, serialize_kwargs={"fields": ["text", "integer"]}
+ )
+
+ @override_settings(AUDITLOG_INCLUDE_ALL_MODELS=True)
+ def test_register_from_settings_register_all_models_excluding_non_managed_models(
+ self,
+ ):
+ self.test_auditlog.register_from_settings()
+
+ self.assertFalse(self.test_auditlog.contains(SimpleNonManagedModel))
+
+ @override_settings(AUDITLOG_INCLUDE_ALL_MODELS=True)
+ def test_register_from_settings_register_all_models_and_figure_out_m2m_fields(self):
+ self.test_auditlog.register_from_settings()
+
+ self.assertIn(
+ "related", self.test_auditlog._registry[AutoManyRelatedModel]["m2m_fields"]
+ )
+
+ @override_settings(AUDITLOG_INCLUDE_ALL_MODELS=True)
+ def test_register_from_settings_register_all_models_including_auto_created_models(
+ self,
+ ):
+ self.test_auditlog.register_from_settings()
+
+ self.assertTrue(
+ self.test_auditlog.contains(AutoManyRelatedModel.related.through)
+ )
+
class ChoicesFieldModelTest(TestCase):
def setUp(self):
@@ -1116,7 +1440,6 @@ def setUp(self):
)
def test_changes_display_dict_single_choice(self):
-
self.assertEqual(
self.obj.history.latest().changes_display_dict["status"][1],
"Red",
@@ -1151,7 +1474,7 @@ def test_changes_display_dict_many_to_one_relation(self):
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
@@ -1233,31 +1556,72 @@ def test_changes_display_dict_arrayfield(self):
class AdminPanelTest(TestCase):
- @classmethod
- def setUpTestData(cls):
- cls.username = "test_admin"
- cls.password = User.objects.make_random_password()
- cls.user, created = User.objects.get_or_create(username=cls.username)
- cls.user.set_password(cls.password)
- cls.user.is_staff = True
- cls.user.is_superuser = True
- cls.user.is_active = True
- cls.user.save()
- cls.obj = SimpleModel.objects.create(text="For admin logentry test")
+ def setUp(self):
+ self.user = User.objects.create_user(
+ username="test_admin", is_staff=True, is_superuser=True, is_active=True
+ )
+ self.site = AdminSite()
+ self.admin = LogEntryAdmin(LogEntry, self.site)
+ 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.login(username=self.username, password=self.password)
+ self.client.force_login(self.user)
log_pk = self.obj.history.latest().pk
res = self.client.get("/admin/auditlog/logentry/")
- assert res.status_code == 200
+ self.assertEqual(res.status_code, 200)
res = self.client.get("/admin/auditlog/logentry/add/")
- assert res.status_code == 403
+ self.assertEqual(res.status_code, 403)
res = self.client.get(f"/admin/auditlog/logentry/{log_pk}/", follow=True)
- assert res.status_code == 200
+ self.assertEqual(res.status_code, 200)
res = self.client.get(f"/admin/auditlog/logentry/{log_pk}/delete/")
- assert res.status_code == 200
+ self.assertEqual(res.status_code, 403)
res = self.client.get(f"/admin/auditlog/logentry/{log_pk}/history/")
- assert res.status_code == 200
+ self.assertEqual(res.status_code, 200)
+
+ def test_created_timezone(self):
+ log_entry = self.obj.history.latest()
+
+ for tz, timestamp in [
+ ("UTC", "2022-08-01 12:00:00"),
+ ("Asia/Tbilisi", "2022-08-01 16:00:00"),
+ ("America/Buenos_Aires", "2022-08-01 09:00:00"),
+ ("Asia/Kathmandu", "2022-08-01 17:45:00"),
+ ]:
+ with self.settings(TIME_ZONE=tz):
+ self.assertEqual(self.admin.created(log_entry), 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)
+
+ def test_has_delete_permission(self):
+ log = self.obj.history.latest()
+ obj_pk = self.obj.pk
+ delete_log_request = RequestFactory().post(
+ f"/admin/auditlog/logentry/{log.pk}/delete/"
+ )
+ delete_log_request.resolver_match = resolve(delete_log_request.path)
+ delete_log_request.user = self.user
+ delete_object_request = RequestFactory().post(
+ f"/admin/tests/simplemodel/{obj_pk}/delete/"
+ )
+ delete_object_request.resolver_match = resolve(delete_object_request.path)
+ delete_object_request.user = self.user
+
+ self.assertTrue(self.admin.has_delete_permission(delete_object_request, log))
+ self.assertFalse(self.admin.has_delete_permission(delete_log_request, log))
class DiffMsgTest(TestCase):
@@ -1270,13 +1634,46 @@ def _create_log_entry(self, action, changes):
return LogEntry.objects.log_create(
SimpleModel.objects.create(), # doesn't affect anything
action=action,
- changes=json.dumps(changes),
+ changes=changes,
+ )
+
+ def test_change_msg_create_when_exceeds_max_len(self):
+ log_entry = self._create_log_entry(
+ LogEntry.Action.CREATE,
+ {
+ "Camelopardalis": [None, "Giraffe"],
+ "Capricornus": [None, "Sea goat"],
+ "Equuleus": [None, "Little horse"],
+ "Horologium": [None, "Clock"],
+ "Microscopium": [None, "Microscope"],
+ "Reticulum": [None, "Net"],
+ "Telescopium": [None, "Telescope"],
+ },
+ )
+
+ self.assertEqual(
+ self.admin.msg_short(log_entry),
+ "7 changes: Camelopardalis, Capricornus, Equuleus, Horologium, "
+ "Microscopium, ..",
)
def test_changes_msg_delete(self):
- log_entry = self._create_log_entry(LogEntry.Action.DELETE, {})
+ log_entry = self._create_log_entry(
+ LogEntry.Action.DELETE,
+ {"field one": ["value before deletion", None], "field two": [11, None]},
+ )
- self.assertEqual(self.admin.msg(log_entry), "")
+ self.assertEqual(self.admin.msg_short(log_entry), "")
+ self.assertEqual(
+ self.admin.msg(log_entry),
+ (
+ ""
+ "| # | Field | From | To |
"
+ "| 1 | Field one | value before deletion | None |
"
+ "| 2 | Field two | 11 | None |
"
+ "
"
+ ),
+ )
def test_changes_msg_create(self):
log_entry = self._create_log_entry(
@@ -1287,13 +1684,16 @@ def test_changes_msg_create(self):
},
)
+ self.assertEqual(
+ self.admin.msg_short(log_entry), "2 changes: field two, field one"
+ )
self.assertEqual(
self.admin.msg(log_entry),
(
""
"| # | Field | From | To |
"
- "| 1 | field one | None | a value |
"
- "| 2 | field two | None | 11 |
"
+ "| 1 | Field one | None | a value |
"
+ "| 2 | Field two | None | 11 |
"
"
"
),
)
@@ -1307,14 +1707,17 @@ def test_changes_msg_update(self):
},
)
+ self.assertEqual(
+ self.admin.msg_short(log_entry), "2 changes: field two, field one"
+ )
self.assertEqual(
self.admin.msg(log_entry),
(
""
"| # | Field | From | To |
"
- "| 1 | field one | old value of field one | "
+ "
| 1 | Field one | old value of field one | "
"new value of field one |
"
- "| 2 | field two | 11 | 42 |
"
+ "| 2 | Field two | 11 | 42 |
"
"
"
),
)
@@ -1331,17 +1734,57 @@ def test_changes_msg_m2m(self):
},
)
+ self.assertEqual(self.admin.msg_short(log_entry), "1 change: some_m2m_field")
self.assertEqual(
self.admin.msg(log_entry),
(
""
"| # | Relationship | Action | Objects |
"
- "| 1 | some_m2m_field | add | Example User (user 1)"
+ " |
| 1 | Some m2m field | add | Example User (user 1)"
" Illustration (user 42) |
"
"
"
),
)
+ def test_unregister_after_log(self):
+ log_entry = self._create_log_entry(
+ LogEntry.Action.CREATE,
+ {
+ "field two": [None, 11],
+ "field one": [None, "a value"],
+ },
+ )
+ # Unregister
+ auditlog.unregister(SimpleModel)
+ self.assertEqual(
+ self.admin.msg_short(log_entry), "2 changes: field two, field one"
+ )
+ self.assertEqual(
+ self.admin.msg(log_entry),
+ (
+ ""
+ "| # | Field | From | To |
"
+ "| 1 | Field one | None | a value |
"
+ "| 2 | Field two | None | 11 |
"
+ "
"
+ ),
+ )
+ # Re-register
+ auditlog.register(SimpleModel)
+
+ def test_field_verbose_name(self):
+ log_entry = self._create_log_entry(
+ LogEntry.Action.CREATE,
+ {"test": "test"},
+ )
+
+ self.assertEqual(self.admin.field_verbose_name(log_entry, "actor"), "Actor")
+ with patch(
+ "django.contrib.contenttypes.models.ContentType.model_class",
+ return_value=None,
+ ):
+ self.assertEqual(self.admin.field_verbose_name(log_entry, "actor"), "actor")
+
class NoDeleteHistoryTest(TestCase):
def test_delete_related(self):
@@ -1398,9 +1841,9 @@ def test_update(self):
history = obj.history.get(action=LogEntry.Action.UPDATE)
- self.assertJSONEqual(
+ self.assertDictEqual(
history.changes,
- '{"json": ["{}", "{\'quantity\': \'1\'}"]}',
+ {"json": ["{}", '{"quantity": "1"}']},
msg="The change is correctly logged",
)
@@ -1491,3 +1934,533 @@ def test_when_field_doesnt_exist(self):
{"boolean": ("True", "False")},
msg="ObjectDoesNotExist should be handled",
)
+
+ def test_diff_models_with_json_fields(self):
+ first = JSONModel.objects.create(
+ json={
+ "code": "17",
+ "date": datetime.date(2022, 1, 1),
+ "description": "first",
+ }
+ )
+ first.refresh_from_db() # refresh json data from db
+ second = JSONModel.objects.create(
+ json={
+ "code": "17",
+ "description": "second",
+ "date": datetime.date(2023, 1, 1),
+ }
+ )
+ diff = model_instance_diff(first, second, ["json"])
+
+ self.assertDictEqual(
+ diff,
+ {
+ "json": (
+ '{"code": "17", "date": "2022-01-01", "description": "first"}',
+ '{"code": "17", "date": "2023-01-01", "description": "second"}',
+ )
+ },
+ )
+
+
+class TestModelSerialization(TestCase):
+ def setUp(self):
+ super().setUp()
+ self.test_date = datetime.datetime(2022, 1, 1, 12, tzinfo=timezone.utc)
+ self.test_date_string = datetime.datetime.strftime(
+ self.test_date, "%Y-%m-%dT%XZ"
+ )
+
+ def test_does_not_serialize_data_when_not_configured(self):
+ instance = SimpleModel.objects.create(
+ text="sample text here", boolean=True, integer=4
+ )
+
+ log = instance.history.first()
+ self.assertIsNone(log.serialized_data)
+
+ def test_serializes_data_on_create(self):
+ with freezegun.freeze_time(self.test_date):
+ instance = SerializeThisModel.objects.create(
+ label="test label",
+ timestamp=self.test_date,
+ nullable=4,
+ nested={"foo": True, "bar": False},
+ )
+
+ log = instance.history.first()
+ self.assertTrue(isinstance(log, LogEntry))
+ self.assertEqual(log.action, 0)
+ self.assertDictEqual(
+ log.serialized_data["fields"],
+ {
+ "label": "test label",
+ "timestamp": self.test_date_string,
+ "nullable": 4,
+ "nested": {"foo": True, "bar": False},
+ "mask_me": None,
+ "date": None,
+ "code": None,
+ },
+ )
+
+ def test_serializes_data_on_update(self):
+ with freezegun.freeze_time(self.test_date):
+ instance = SerializeThisModel.objects.create(
+ label="test label",
+ timestamp=self.test_date,
+ nullable=4,
+ nested={"foo": True, "bar": False},
+ )
+
+ update_date = self.test_date + datetime.timedelta(days=4)
+ with freezegun.freeze_time(update_date):
+ instance.label = "test label change"
+ instance.save()
+
+ log = instance.history.filter(timestamp=update_date).first()
+ self.assertTrue(isinstance(log, LogEntry))
+ self.assertEqual(log.action, 1)
+ self.assertDictEqual(
+ log.serialized_data["fields"],
+ {
+ "label": "test label change",
+ "timestamp": self.test_date_string,
+ "nullable": 4,
+ "nested": {"foo": True, "bar": False},
+ "mask_me": None,
+ "date": None,
+ "code": None,
+ },
+ )
+
+ def test_serializes_data_on_delete(self):
+ with freezegun.freeze_time(self.test_date):
+ instance = SerializeThisModel.objects.create(
+ label="test label",
+ timestamp=self.test_date,
+ nullable=4,
+ nested={"foo": True, "bar": False},
+ )
+
+ obj_id = int(instance.id)
+ delete_date = self.test_date + datetime.timedelta(days=4)
+ with freezegun.freeze_time(delete_date):
+ instance.delete()
+
+ log = LogEntry.objects.filter(object_id=obj_id, timestamp=delete_date).first()
+ self.assertTrue(isinstance(log, LogEntry))
+ self.assertEqual(log.action, 2)
+ self.assertDictEqual(
+ log.serialized_data["fields"],
+ {
+ "label": "test label",
+ "timestamp": self.test_date_string,
+ "nullable": 4,
+ "nested": {"foo": True, "bar": False},
+ "mask_me": None,
+ "date": None,
+ "code": None,
+ },
+ )
+
+ def test_serialize_string_representations(self):
+ with freezegun.freeze_time(self.test_date):
+ instance = SerializeThisModel.objects.create(
+ label="test label",
+ nullable=4,
+ nested={"foo": 10, "bar": False},
+ timestamp="2022-03-01T12:00Z",
+ date="2022-04-05",
+ code="e82d5e53-ca80-4037-af55-b90752326460",
+ )
+
+ log = instance.history.first()
+ self.assertTrue(isinstance(log, LogEntry))
+ self.assertEqual(log.action, 0)
+ self.assertDictEqual(
+ log.serialized_data["fields"],
+ {
+ "label": "test label",
+ "timestamp": "2022-03-01T12:00:00Z",
+ "date": "2022-04-05",
+ "code": "e82d5e53-ca80-4037-af55-b90752326460",
+ "nullable": 4,
+ "nested": {"foo": 10, "bar": False},
+ "mask_me": None,
+ },
+ )
+
+ def test_serialize_mask_fields(self):
+ with freezegun.freeze_time(self.test_date):
+ instance = SerializeThisModel.objects.create(
+ label="test label",
+ nullable=4,
+ timestamp=self.test_date,
+ nested={"foo": 10, "bar": False},
+ mask_me="confidential",
+ )
+
+ log = instance.history.first()
+ self.assertTrue(isinstance(log, LogEntry))
+ self.assertEqual(log.action, 0)
+ self.assertDictEqual(
+ log.serialized_data["fields"],
+ {
+ "label": "test label",
+ "timestamp": self.test_date_string,
+ "nullable": 4,
+ "nested": {"foo": 10, "bar": False},
+ "mask_me": "******ential",
+ "date": None,
+ "code": None,
+ },
+ )
+
+ def test_serialize_only_auditlog_fields(self):
+ with freezegun.freeze_time(self.test_date):
+ instance = SerializeOnlySomeOfThisModel.objects.create(
+ this="this should be there", not_this="leave this bit out"
+ )
+
+ log = instance.history.first()
+ self.assertTrue(isinstance(log, LogEntry))
+ self.assertEqual(log.action, 0)
+ self.assertDictEqual(
+ log.serialized_data["fields"], {"this": "this should be there"}
+ )
+ self.assertDictEqual(
+ log.changes_dict,
+ {"this": ["None", "this should be there"], "id": ["None", "1"]},
+ )
+
+ def test_serialize_related(self):
+ with freezegun.freeze_time(self.test_date):
+ serialize_this = SerializeThisModel.objects.create(
+ label="test label",
+ nested={"foo": "bar"},
+ timestamp=self.test_date,
+ )
+ instance = SerializePrimaryKeyRelatedModel.objects.create(
+ serialize_this=serialize_this,
+ subheading="use a primary key for this serialization, please.",
+ value=10,
+ )
+
+ log = instance.history.first()
+ self.assertTrue(isinstance(log, LogEntry))
+ self.assertEqual(log.action, 0)
+ self.assertDictEqual(
+ log.serialized_data["fields"],
+ {
+ "serialize_this": serialize_this.id,
+ "subheading": "use a primary key for this serialization, please.",
+ "value": 10,
+ },
+ )
+
+ def test_serialize_related_with_kwargs(self):
+ with freezegun.freeze_time(self.test_date):
+ serialize_this = SerializeThisModel.objects.create(
+ label="test label",
+ nested={"foo": "bar"},
+ timestamp=self.test_date,
+ )
+ instance = SerializeNaturalKeyRelatedModel.objects.create(
+ serialize_this=serialize_this,
+ subheading="use a natural key for this serialization, please.",
+ value=11,
+ )
+
+ log = instance.history.first()
+ self.assertTrue(isinstance(log, LogEntry))
+ self.assertEqual(log.action, 0)
+ self.assertDictEqual(
+ log.serialized_data["fields"],
+ {
+ "serialize_this": "test label",
+ "subheading": "use a natural key for this serialization, please.",
+ "value": 11,
+ },
+ )
+
+ def test_f_expressions(self):
+ serialize_this = SerializeThisModel.objects.create(
+ label="test label",
+ nested={"foo": "bar"},
+ timestamp=self.test_date,
+ nullable=1,
+ )
+ serialize_this.nullable = models.F("nullable") + 1
+ serialize_this.save()
+
+ log = serialize_this.history.first()
+ self.assertTrue(isinstance(log, LogEntry))
+ self.assertEqual(log.action, 1)
+ self.assertEqual(
+ log.serialized_data["fields"]["nullable"],
+ "F(nullable) + Value(1)",
+ )
+
+
+class TestAccessLog(TestCase):
+ def setUp(self):
+ self.user = User.objects.create_user(username="test_user", is_active=True)
+ self.obj = SimpleModel.objects.create(text="For admin logentry test")
+
+ def test_access_log(self):
+ self.client.force_login(self.user)
+ content_type = ContentType.objects.get_for_model(self.obj.__class__)
+
+ # Check for log entries
+ qs = LogEntry.objects.filter(content_type=content_type, object_pk=self.obj.pk)
+ old_count = qs.count()
+
+ self.client.get(reverse("simplemodel-detail", args=[self.obj.pk]))
+ new_count = qs.count()
+ self.assertEqual(new_count, old_count + 1)
+
+ log_entry = qs.latest()
+ self.assertEqual(int(log_entry.object_pk), self.obj.pk)
+ self.assertEqual(log_entry.actor, self.user)
+ self.assertEqual(log_entry.content_type, content_type)
+ self.assertEqual(
+ log_entry.action, LogEntry.Action.ACCESS, msg="Action is 'ACCESS'"
+ )
+ self.assertIsNone(log_entry.changes)
+ self.assertEqual(log_entry.changes_dict, {})
+
+
+class SignalTests(TestCase):
+ def setUp(self):
+ self.obj = SimpleModel.objects.create(text="I am not difficult.")
+ self.my_pre_log_data = {
+ "is_called": False,
+ "my_sender": None,
+ "my_instance": None,
+ "my_action": None,
+ }
+ self.my_post_log_data = {
+ "is_called": False,
+ "my_sender": None,
+ "my_instance": None,
+ "my_action": None,
+ "my_error": None,
+ }
+
+ def assertSignals(self, action):
+ self.assertTrue(
+ self.my_pre_log_data["is_called"], "pre_log hook receiver not called"
+ )
+ self.assertIs(self.my_pre_log_data["my_sender"], self.obj.__class__)
+ self.assertIs(self.my_pre_log_data["my_instance"], self.obj)
+ self.assertEqual(self.my_pre_log_data["my_action"], action)
+
+ self.assertTrue(
+ self.my_post_log_data["is_called"], "post_log hook receiver not called"
+ )
+ self.assertIs(self.my_post_log_data["my_sender"], self.obj.__class__)
+ self.assertIs(self.my_post_log_data["my_instance"], self.obj)
+ self.assertEqual(self.my_post_log_data["my_action"], action)
+ self.assertIsNone(self.my_post_log_data["my_error"])
+
+ def test_custom_signals(self):
+ my_ret_val = random.randint(0, 10000)
+ my_other_ret_val = random.randint(0, 10000)
+
+ def pre_log_receiver(sender, instance, action, **_kwargs):
+ self.my_pre_log_data["is_called"] = True
+ self.my_pre_log_data["my_sender"] = sender
+ self.my_pre_log_data["my_instance"] = instance
+ self.my_pre_log_data["my_action"] = action
+ return my_ret_val
+
+ def pre_log_receiver_extra(*_args, **_kwargs):
+ return my_other_ret_val
+
+ def post_log_receiver(
+ sender, instance, action, error, pre_log_results, **_kwargs
+ ):
+ self.my_post_log_data["is_called"] = True
+ self.my_post_log_data["my_sender"] = sender
+ self.my_post_log_data["my_instance"] = instance
+ self.my_post_log_data["my_action"] = action
+ self.my_post_log_data["my_error"] = error
+
+ self.assertEqual(len(pre_log_results), 2)
+
+ found_first_result = False
+ found_second_result = False
+ for pre_log_fn, pre_log_result in pre_log_results:
+ if pre_log_fn is pre_log_receiver and pre_log_result == my_ret_val:
+ found_first_result = True
+ for pre_log_fn, pre_log_result in pre_log_results:
+ if (
+ pre_log_fn is pre_log_receiver_extra
+ and pre_log_result == my_other_ret_val
+ ):
+ found_second_result = True
+
+ self.assertTrue(found_first_result)
+ self.assertTrue(found_second_result)
+
+ return my_ret_val
+
+ pre_log.connect(pre_log_receiver)
+ pre_log.connect(pre_log_receiver_extra)
+ post_log.connect(post_log_receiver)
+
+ self.obj = SimpleModel.objects.create(text="I am not difficult.")
+
+ self.assertSignals(LogEntry.Action.CREATE)
+
+ def test_custom_signals_update(self):
+ def pre_log_receiver(sender, instance, action, **_kwargs):
+ self.my_pre_log_data["is_called"] = True
+ self.my_pre_log_data["my_sender"] = sender
+ self.my_pre_log_data["my_instance"] = instance
+ self.my_pre_log_data["my_action"] = action
+
+ def post_log_receiver(sender, instance, action, error, **_kwargs):
+ self.my_post_log_data["is_called"] = True
+ self.my_post_log_data["my_sender"] = sender
+ self.my_post_log_data["my_instance"] = instance
+ self.my_post_log_data["my_action"] = action
+ self.my_post_log_data["my_error"] = error
+
+ pre_log.connect(pre_log_receiver)
+ post_log.connect(post_log_receiver)
+
+ self.obj.text = "Changed Text"
+ self.obj.save()
+
+ self.assertSignals(LogEntry.Action.UPDATE)
+
+ def test_custom_signals_delete(self):
+ def pre_log_receiver(sender, instance, action, **_kwargs):
+ self.my_pre_log_data["is_called"] = True
+ self.my_pre_log_data["my_sender"] = sender
+ self.my_pre_log_data["my_instance"] = instance
+ self.my_pre_log_data["my_action"] = action
+
+ def post_log_receiver(sender, instance, action, error, **_kwargs):
+ self.my_post_log_data["is_called"] = True
+ self.my_post_log_data["my_sender"] = sender
+ self.my_post_log_data["my_instance"] = instance
+ self.my_post_log_data["my_action"] = action
+ self.my_post_log_data["my_error"] = error
+
+ pre_log.connect(pre_log_receiver)
+ post_log.connect(post_log_receiver)
+
+ self.obj.delete()
+
+ self.assertSignals(LogEntry.Action.DELETE)
+
+ @patch("auditlog.receivers.LogEntry.objects")
+ def test_signals_errors(self, log_entry_objects_mock):
+ class CustomSignalError(BaseException):
+ pass
+
+ def post_log_receiver(error, **_kwargs):
+ self.my_post_log_data["my_error"] = error
+
+ post_log.connect(post_log_receiver)
+
+ # create
+ error_create = CustomSignalError(LogEntry.Action.CREATE)
+ log_entry_objects_mock.log_create.side_effect = error_create
+ with self.assertRaises(CustomSignalError):
+ SimpleModel.objects.create(text="I am not difficult.")
+ self.assertEqual(self.my_post_log_data["my_error"], error_create)
+
+ # update
+ error_update = CustomSignalError(LogEntry.Action.UPDATE)
+ log_entry_objects_mock.log_create.side_effect = error_update
+ with self.assertRaises(CustomSignalError):
+ obj = SimpleModel.objects.get(pk=self.obj.pk)
+ obj.text = "updating"
+ obj.save()
+ self.assertEqual(self.my_post_log_data["my_error"], error_update)
+
+ # delete
+ error_delete = CustomSignalError(LogEntry.Action.DELETE)
+ log_entry_objects_mock.log_create.side_effect = error_delete
+ with self.assertRaises(CustomSignalError):
+ obj = SimpleModel.objects.get(pk=self.obj.pk)
+ obj.delete()
+ self.assertEqual(self.my_post_log_data["my_error"], error_delete)
+
+
+@override_settings(AUDITLOG_DISABLE_ON_RAW_SAVE=True)
+class DisableTest(TestCase):
+ """
+ All the other tests check logging, so this only needs to test disabled logging.
+ """
+
+ def test_create(self):
+ # Mimic the way imports create objects
+ inst = SimpleModel(
+ text="I am a bit more difficult.",
+ boolean=False,
+ datetime=django_timezone.now(),
+ )
+ SimpleModel.save_base(inst, raw=True)
+ self.assertEqual(0, LogEntry.objects.get_for_object(inst).count())
+
+ def test_create_with_context_manager(self):
+ with disable_auditlog():
+ inst = SimpleModel.objects.create(text="I am a bit more difficult.")
+ self.assertEqual(0, LogEntry.objects.get_for_object(inst).count())
+
+ def test_update(self):
+ inst = SimpleModel(
+ text="I am a bit more difficult.",
+ boolean=False,
+ datetime=django_timezone.now(),
+ )
+ SimpleModel.save_base(inst, raw=True)
+ inst.text = "I feel refreshed"
+ inst.save_base(raw=True)
+ self.assertEqual(0, LogEntry.objects.get_for_object(inst).count())
+
+ def test_update_with_context_manager(self):
+ inst = SimpleModel(
+ text="I am a bit more difficult.",
+ boolean=False,
+ datetime=django_timezone.now(),
+ )
+ SimpleModel.save_base(inst, raw=True)
+ with disable_auditlog():
+ inst.text = "I feel refreshed"
+ inst.save()
+ self.assertEqual(0, LogEntry.objects.get_for_object(inst).count())
+
+ def test_m2m(self):
+ """
+ Create m2m from fixture and check that nothing was logged.
+ This only works with context manager
+ """
+ with disable_auditlog():
+ management.call_command("loaddata", "m2m_test_fixture.json", verbosity=0)
+ recursive = ManyRelatedModel.objects.get(pk=1)
+ self.assertEqual(0, LogEntry.objects.get_for_object(recursive).count())
+ related = ManyRelatedOtherModel.objects.get(pk=1)
+ self.assertEqual(0, LogEntry.objects.get_for_object(related).count())
+
+
+class MissingModelTest(TestCase):
+ def setUp(self):
+ # Create a log entry, then unregister the model
+ self.obj = SimpleModel.objects.create(text="I am old.")
+ auditlog.unregister(SimpleModel)
+
+ def tearDown(self):
+ # Re-register the model for other tests
+ auditlog.register(SimpleModel)
+
+ def test_get_changes_for_missing_model(self):
+ history = self.obj.history.latest()
+ self.assertEqual(history.changes_dict["text"][1], self.obj.text)
+ self.assertEqual(history.changes_display_dict["text"][1], self.obj.text)
diff --git a/auditlog_tests/urls.py b/auditlog_tests/urls.py
index 083932c6..712071d2 100644
--- a/auditlog_tests/urls.py
+++ b/auditlog_tests/urls.py
@@ -1,6 +1,13 @@
from django.contrib import admin
from django.urls import path
+from auditlog_tests.views import SimpleModelDetailview
+
urlpatterns = [
path("admin/", admin.site.urls),
+ path(
+ "simplemodel//",
+ SimpleModelDetailview.as_view(),
+ name="simplemodel-detail",
+ ),
]
diff --git a/auditlog_tests/views.py b/auditlog_tests/views.py
new file mode 100644
index 00000000..436ecbfd
--- /dev/null
+++ b/auditlog_tests/views.py
@@ -0,0 +1,9 @@
+from django.views.generic import DetailView
+
+from auditlog.mixins import LogAccessMixin
+from auditlog_tests.models import SimpleModel
+
+
+class SimpleModelDetailview(LogAccessMixin, DetailView):
+ model = SimpleModel
+ template_name = "simplemodel_detail.html"
diff --git a/docs/make.bat b/docs/make.bat
index 447b8bdb..2ef50ff0 100644
--- a/docs/make.bat
+++ b/docs/make.bat
@@ -21,7 +21,7 @@ if errorlevel 9009 (
echo.may add the Sphinx directory to PATH.
echo.
echo.If you don't have Sphinx installed, grab it from
- echo.http://sphinx-doc.org/
+ echo.https://sphinx-doc.org/
exit /b 1
)
diff --git a/docs/requirements.txt b/docs/requirements.txt
index 597a7cfd..eb4b4b66 100644
--- a/docs/requirements.txt
+++ b/docs/requirements.txt
@@ -1,5 +1,5 @@
# Docs requirements
-django>=3.2,<3.3
+django>=4.2,<4.3
sphinx
sphinx_rtd_theme
psycopg2-binary
diff --git a/docs/source/conf.py b/docs/source/conf.py
index 710a457d..f6689fa8 100644
--- a/docs/source/conf.py
+++ b/docs/source/conf.py
@@ -10,7 +10,7 @@
import sys
from datetime import date
-from pkg_resources import get_distribution
+from auditlog import __version__
# -- Path setup --------------------------------------------------------------
@@ -33,7 +33,7 @@
author = "Jan-Jelle Kester and contributors"
copyright = f"2013-{date.today().year}, {author}"
-release = get_distribution("django-auditlog").version
+release = __version__
# for example take major/minor
version = ".".join(release.split(".")[:2])
diff --git a/docs/source/index.rst b/docs/source/index.rst
index bf58a72d..d4f1a469 100644
--- a/docs/source/index.rst
+++ b/docs/source/index.rst
@@ -21,6 +21,7 @@ Contents
installation
usage
+ upgrade
internals
diff --git a/docs/source/installation.rst b/docs/source/installation.rst
index e6b0233a..b6b197a1 100644
--- a/docs/source/installation.rst
+++ b/docs/source/installation.rst
@@ -11,10 +11,10 @@ The repository can be found at https://github.com/jazzband/django-auditlog/.
**Requirements**
-- Python 3.7 or higher
-- Django 3.2 or higher
+- Python 3.9 or higher
+- Django 4.2, 5.0 and 5.1
-Auditlog is currently tested with Python 3.7+ and Django 3.2 and 4.0. The latest test report can be found
+Auditlog is currently tested with Python 3.9+ and Django 4.2, 5.0 and 5.1. The latest test report can be found
at https://github.com/jazzband/django-auditlog/actions.
Adding Auditlog to your Django application
diff --git a/docs/source/internals.rst b/docs/source/internals.rst
index 4964a06d..9c869c12 100644
--- a/docs/source/internals.rst
+++ b/docs/source/internals.rst
@@ -19,12 +19,30 @@ Middleware
.. automodule:: auditlog.middleware
:members: AuditlogMiddleware
+Correlation ID
+--------------
+
+.. automodule:: auditlog.cid
+ :members: get_cid, set_cid
+
Signal receivers
----------------
.. automodule:: auditlog.receivers
:members:
+Custom Signals
+--------------
+Django Auditlog provides two custom signals that will hook in before
+and after any Auditlog record is written from a ``create``, ``update``,
+``delete``, or ``accessed`` action on an audited model.
+
+.. automodule:: auditlog.signals
+ :members:
+ :member-order: bysource
+
+.. versionadded:: 3.0.0
+
Calculating changes
-------------------
diff --git a/docs/source/upgrade.rst b/docs/source/upgrade.rst
new file mode 100644
index 00000000..19410252
--- /dev/null
+++ b/docs/source/upgrade.rst
@@ -0,0 +1,21 @@
+Upgrading to version 3
+======================
+
+Version 3.0.0 introduces breaking changes. Please review the migration guide below before upgrading.
+If you're new to django-auditlog, you can ignore this part.
+
+The major change in the version is that we're finally storing changes as json instead of json-text.
+To convert the existing records, this version has a database migration that does just that.
+However, this migration will take a long time if you have a huge amount of records,
+causing your database and application to be out of sync until the migration is complete.
+
+To avoid this, follow these steps:
+
+1. Before upgrading the package, add these two variables to ``settings.py``:
+
+ * ``AUDITLOG_TWO_STEP_MIGRATION = True``
+ * ``AUDITLOG_USE_TEXT_CHANGES_IF_JSON_IS_NOT_PRESENT = True``
+
+2. Upgrade the package. Your app will now start storing new records as JSON, but the old records will accessible via ``LogEntry.changes_text``.
+3. Use the newly added ``auditlogmigratejson`` command to migrate your records. Run ``django-admin auditlogmigratejson --help`` to get more information.
+4. Once all records are migrated, remove the variables listed above, or set their values to ``False``.
diff --git a/docs/source/usage.rst b/docs/source/usage.rst
index eaa3ed3b..843c189d 100644
--- a/docs/source/usage.rst
+++ b/docs/source/usage.rst
@@ -19,7 +19,7 @@ Automatically logging changes
Auditlog can automatically log changes to objects for you. This functionality is based on Django's signals, but linking
your models to Auditlog is even easier than using signals.
-Registering your model for logging can be done with a single line of code, as the following example illustrates::
+Registering your model for logging can be done with a single line of code, as the following example illustrates:
.. code-block:: python
@@ -37,6 +37,41 @@ It is recommended to place the register code (``auditlog.register(MyModel)``) at
This ensures that every time your model is imported it will also be registered to log changes. Auditlog makes sure that
each model is only registered once, otherwise duplicate log entries would occur.
+
+**Logging access**
+
+By default, Auditlog will only log changes to your model instances. If you want to log access to your model instances as well, Auditlog provides a mixin class for that purpose. Simply add the :py:class:`auditlog.mixins.LogAccessMixin` to your class based view and Auditlog will log access to your model instances. The mixin expects your view to have a ``get_object`` method that returns the model instance for which access shall be logged - this is usually the case for DetailViews and UpdateViews.
+
+A DetailView utilizing the LogAccessMixin could look like the following example:
+
+.. code-block:: python
+
+ from django.views.generic import DetailView
+
+ from auditlog.mixins import LogAccessMixin
+
+ class MyModelDetailView(LogAccessMixin, DetailView):
+ model = MyModel
+
+ # View code goes here
+
+You can also add log-access to function base views, as the following example illustrates:
+
+.. code-block:: python
+
+ from auditlog.signals import accessed
+
+ def profile_view(request, pk):
+ ## get the object you want to log access
+ user = User.objects.get(pk=pk)
+
+ ## log access
+ accessed.send(user.__class__, instance=user)
+
+ # View code goes here
+ ...
+
+
**Excluding fields**
Fields that are excluded will not trigger saving a new log entry and will not show up in the recorded changes.
@@ -115,6 +150,48 @@ Note that when the user changes multiple many-to-many fields on the same object
.. versionadded:: 2.1.0
+**Serialized Data**
+
+The state of an object following a change action may be optionally serialized and persisted in the ``LogEntry.serialized_data`` JSONField. To enable this feature for a registered model, add ``serialize_data=True`` to the kwargs on the ``auditlog.register(...)`` method. Object serialization will not occur unless this kwarg is set.
+
+.. code-block:: python
+
+ auditlog.register(MyModel, serialize_data=True)
+
+Objects are serialized using the Django core serializer. Keyword arguments may be passed to the serializer through ``serialize_kwargs``.
+
+.. code-block:: python
+
+ auditlog.register(
+ MyModel,
+ serialize_data=True,
+ serialize_kwargs={"fields": ["foo", "bar", "biz", "baz"]}
+ )
+
+Note that all fields on the object will be serialized unless restricted with one or more configurations. The `serialize_kwargs` option contains a `fields` argument and this may be given an inclusive list of field names to serialize (as shown above). Alternatively, one may set ``serialize_auditlog_fields_only`` to ``True`` when registering a model with ``exclude_fields`` and ``include_fields`` set (as shown below). This will cause the data persisted in ``LogEntry.serialized_data`` to be limited to the same scope that is persisted within the ``LogEntry.changes`` field.
+
+.. code-block:: python
+
+ auditlog.register(
+ MyModel,
+ exclude_fields=["ssn", "confidential"]
+ serialize_data=True,
+ 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.
+
+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
--------
@@ -128,6 +205,36 @@ You can use this setting to register all your models:
.. versionadded:: 2.1.0
+**AUDITLOG_EXCLUDE_TRACKING_FIELDS**
+
+You can use this setting to exclude named fields from ALL models.
+This is useful when lots of models share similar fields like
+```created``` and ```modified``` and you want those excluded from
+logging.
+It will be considered when ``AUDITLOG_INCLUDE_ALL_MODELS`` is `True`.
+
+.. code-block:: python
+
+ AUDITLOG_EXCLUDE_TRACKING_FIELDS = (
+ "created",
+ "modified"
+ )
+
+.. versionadded:: 3.0.0
+
+**AUDITLOG_EXCLUDE_TRACKING_FIELDS**
+
+When using "AuditlogMiddleware",
+the IP address is logged by default, you can use this setting
+to exclude the IP address from logging.
+It will be considered when ``AUDITLOG_DISABLE_REMOTE_ADDR`` is `True`.
+
+.. code-block:: python
+
+ AUDITLOG_DISABLE_REMOTE_ADDR = True
+
+.. versionadded:: 3.0.0
+
**AUDITLOG_EXCLUDE_TRACKING_MODELS**
You can use this setting to exclude models in registration process.
@@ -155,7 +262,7 @@ It must be a list or tuple. Each item in this setting can be a:
AUDITLOG_INCLUDE_TRACKING_MODELS = (
".",
{
- "model": ".",
+ "model": ".",
"include_fields": ["field1", "field2"],
"exclude_fields": ["field3", "field4"],
"mapping_fields": {
@@ -163,12 +270,41 @@ 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_auditlog_fields_only": False,
+ "serialize_kwargs": {"fields": ["foo", "bar", "biz", "baz"]},
},
".",
)
.. versionadded:: 2.1.0
+**AUDITLOG_DISABLE_ON_RAW_SAVE**
+
+Disables logging during raw save. (I.e. for instance using loaddata)
+
+.. note::
+
+ M2M operations will still be logged, since they're never considered `raw`. To disable them
+ you must remove their setting or use the `disable_auditlog` context manager.
+
+.. 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
------
@@ -196,10 +332,11 @@ It is recommended to keep all middleware that alters the request loaded before A
user as actor. To only have some object changes to be logged with the current request's user as actor manual logging is
required.
-Context manager
-***************
+Context managers
+----------------
-.. versionadded:: 2.1.0
+Set actor
+*********
To enable the automatic logging of the actors outside of request context (e.g. in a Celery task), you can use a context
manager::
@@ -212,6 +349,26 @@ manager::
# if your code here leads to creation of LogEntry instances, these will have the actor set
...
+
+.. versionadded:: 2.1.0
+
+
+Disable auditlog
+****************
+
+Disable auditlog temporary, for instance if you need to install a large fixture on a live system or cleanup
+corrupt data::
+
+ from auditlog.context import disable_auditlog
+
+ with disable_auditlog():
+ # Do things silently here
+ ...
+
+
+.. versionadded:: 2.2.0
+
+
Object history
--------------
diff --git a/pyproject.toml b/pyproject.toml
index be27ad00..e3c9789b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,5 +1,5 @@
[tool.black]
-target-version = ["py37"]
+target-version = ["py39"]
[tool.isort]
profile = "black"
diff --git a/setup.py b/setup.py
index 497c2192..ea7f6ca7 100644
--- a/setup.py
+++ b/setup.py
@@ -24,24 +24,24 @@
},
license="MIT",
author="Jan-Jelle Kester",
- maintainer="Alieh Rymašeŭski",
+ maintainer="Ilya Datskevich",
description="Audit log app for Django",
long_description=long_description,
long_description_content_type="text/markdown",
- install_requires=[
- "django-admin-rangefilter>=0.8.0",
- "python-dateutil>=2.6.0",
- ],
+ python_requires=">=3.9",
+ install_requires=["Django>=4.2", "python-dateutil>=2.7.0"],
zip_safe=False,
classifiers=[
"Programming Language :: Python :: 3",
- "Programming Language :: Python :: 3.7",
- "Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
+ "Programming Language :: Python :: 3.11",
+ "Programming Language :: Python :: 3.12",
+ "Programming Language :: Python :: 3.13",
"Framework :: Django",
- "Framework :: Django :: 3.2",
- "Framework :: Django :: 4.0",
+ "Framework :: Django :: 4.2",
+ "Framework :: Django :: 5.0",
+ "Framework :: Django :: 5.1",
"License :: OSI Approved :: MIT License",
],
)
diff --git a/tox.ini b/tox.ini
index e4d36279..1c5bc493 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,9 +1,12 @@
[tox]
envlist =
- {py37,py38,py39,py310}-django32
- {py38,py39,py310}-django{40,main}
- py37-docs
- py38-lint
+ {py39,py310,py311}-django42
+ {py310,py311,py312}-django50
+ {py310,py311,py312,py313}-django51
+ {py310,py311,py312,py313}-django52
+ ; {py312,py313}-djangomain
+ py39-docs
+ py39-lint
[testenv]
setenv =
@@ -12,15 +15,17 @@ commands =
coverage run --source auditlog runtests.py
coverage xml
deps =
- django32: Django>=3.2,<3.3
- django40: Django>=4.0,<4.1
+ django42: Django>=4.2,<4.3
+ django50: Django>=5.0,<5.1
+ django51: Django>=5.1,<5.2
+ django52: Django>=5.2,<6.0
djangomain: https://github.com/django/django/archive/main.tar.gz
# Test requirements
coverage
codecov
django-multiselectfield
freezegun
- psycopg2-binary==2.8.6
+ psycopg2-binary
passenv=
TEST_DB_HOST
TEST_DB_USER
@@ -29,24 +34,26 @@ passenv=
TEST_DB_PORT
basepython =
+ py313: python3.13
+ py312: python3.12
+ py311: python3.11
py310: python3.10
py39: python3.9
- py38: python3.8
- py37: python3.7
-[testenv:py37-docs]
+[testenv:py39-docs]
changedir = docs/source
deps = -rdocs/requirements.txt
commands = sphinx-build -W -b html -d {envtmpdir}/doctrees . {envtmpdir}/html
-[testenv:py38-lint]
+[testenv:py39-lint]
deps = pre-commit
commands =
pre-commit run --all-files
[gh-actions]
python =
- 3.7: py37
- 3.8: py38
3.9: py39
3.10: py310
+ 3.11: py311
+ 3.12: py312
+ 3.13: py313