├── tests ├── __init__.py ├── urls.py ├── conftest.py ├── test_models.py └── test_admin.py ├── django_admin_logs ├── __init__.py ├── settings.py ├── apps.py ├── models.py └── admin.py ├── MANIFEST.in ├── .gitattributes ├── .pre-commit-config.yaml ├── .editorconfig ├── tox.ini ├── LICENSE ├── .gitignore ├── CHANGELOG.rst ├── pyproject.toml └── README.rst /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_admin_logs/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include *.rst 3 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF (line ending) normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /tests/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.urls import path 3 | 4 | urlpatterns = [ 5 | path("admin/", admin.site.urls), 6 | ] 7 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: local 3 | hooks: 4 | - id: black 5 | name: black 6 | entry: black 7 | language: python 8 | types: [python] 9 | - id: ruff 10 | name: ruff 11 | entry: ruff check --no-cache 12 | language: python 13 | types: [python] 14 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # https://editorconfig.org/ 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 4 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | end_of_line = lf 11 | charset = utf-8 12 | 13 | # Docstrings and comments use max_line_length = 79 14 | [*.py] 15 | max_line_length = 88 16 | 17 | # Use 2 spaces for HTML and JSON files 18 | [*.{html,json}] 19 | indent_size = 2 20 | 21 | # Makefiles use tabs for indentation 22 | [Makefile] 23 | indent_style = tab 24 | 25 | # Batch files use tabs for indentation 26 | [*.bat] 27 | indent_style = tab 28 | -------------------------------------------------------------------------------- /django_admin_logs/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django Admin Logs - Settings. 3 | """ 4 | 5 | from django.conf import settings 6 | 7 | # Determines whether admin logs should be enabled (defaults to True). 8 | # If disabled, no log entries are created or displayed in the admin section. 9 | DJANGO_ADMIN_LOGS_ENABLED = getattr(settings, "DJANGO_ADMIN_LOGS_ENABLED", True) 10 | 11 | # Determines whether admin logs can be deleted (defaults to False). 12 | # If enabled, non-superusers will still require the delete_logentry permission. 13 | DJANGO_ADMIN_LOGS_DELETABLE = getattr(settings, "DJANGO_ADMIN_LOGS_DELETABLE", False) 14 | 15 | # Determines whether to ignore (not log) CHANGE actions where no changes were made 16 | # (defaults to False). 17 | DJANGO_ADMIN_LOGS_IGNORE_UNCHANGED = getattr( 18 | settings, "DJANGO_ADMIN_LOGS_IGNORE_UNCHANGED", False 19 | ) 20 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | requires = 3 | tox>=4 4 | envlist = 5 | py3{8-12}-django42 6 | py3{10-14}-django52 7 | py3{12-14}-django60 8 | lint 9 | dist 10 | skip_missing_interpreters = true 11 | 12 | [testenv] 13 | deps = 14 | django42: Django>=4.2,<4.3 15 | django51: Django>=5.2,<5.3 16 | django60: Django>=6.0,<6.1 17 | coverage 18 | pytest 19 | pytest-cov 20 | pytest-django 21 | setenv = 22 | PYTHONDONTWRITEBYTECODE=1 23 | commands = 24 | pytest --cov=django_admin_logs --cov-append 25 | 26 | [testenv:lint] 27 | deps = 28 | black 29 | ruff 30 | skip_install = true 31 | commands = 32 | black --check . 33 | ruff check --no-cache . 34 | 35 | [testenv:dist] 36 | deps = 37 | build 38 | twine 39 | skip_install = true 40 | commands = 41 | python -m build 42 | python -m twine check dist/* 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020 Adam Radwon 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # Unit tests / coverage reports 31 | htmlcov/ 32 | .tox/ 33 | .nox/ 34 | .coverage 35 | .coverage.* 36 | .cache 37 | nosetests.xml 38 | coverage.xml 39 | *.cover 40 | *.py,cover 41 | .hypothesis/ 42 | .pytest_cache/ 43 | cover/ 44 | 45 | # Sphinx documentation 46 | docs/_build/ 47 | 48 | # PyBuilder 49 | target/ 50 | 51 | # Jupyter Notebook 52 | .ipynb_checkpoints 53 | 54 | # IPython 55 | profile_default/ 56 | ipython_config.py 57 | 58 | # Environments 59 | .env 60 | .venv 61 | env/ 62 | venv/ 63 | ENV/ 64 | env.bak/ 65 | venv.bak/ 66 | 67 | # mypy 68 | .mypy_cache/ 69 | .dmypy.json 70 | dmypy.json 71 | 72 | # Pyre type checker 73 | .pyre/ 74 | 75 | # pytype static type analyzer 76 | .pytype/ 77 | 78 | # Local config files 79 | .vscode 80 | -------------------------------------------------------------------------------- /django_admin_logs/apps.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django Admin Logs - App Config. 3 | """ 4 | 5 | from django.apps import AppConfig 6 | 7 | 8 | class DjangoAdminLogsConfig(AppConfig): 9 | name = "django_admin_logs" 10 | verbose_name = "Django Admin Logs" 11 | 12 | def ready(self): 13 | # Import models after apps loaded to avoid AppRegistryNotReady exception 14 | from django.contrib.admin.models import LogEntry 15 | 16 | from .models import ChangedLogEntryManager, LogEntryManager, NoLogEntryManager 17 | from .settings import ( 18 | DJANGO_ADMIN_LOGS_ENABLED, 19 | DJANGO_ADMIN_LOGS_IGNORE_UNCHANGED, 20 | ) 21 | 22 | # Check which LogEntry model manager to use based on settings 23 | if DJANGO_ADMIN_LOGS_ENABLED is False: 24 | # Switch to the model manager that doesn't log 25 | LogEntry.objects = NoLogEntryManager(LogEntry) 26 | elif DJANGO_ADMIN_LOGS_IGNORE_UNCHANGED is True: 27 | # Switch to the model manager that only logs changes 28 | LogEntry.objects = ChangedLogEntryManager(LogEntry) 29 | else: # Use the default model manager 30 | LogEntry.objects = LogEntryManager(LogEntry) 31 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | 1.5.0 (2025-12-03) 5 | ------------------ 6 | * Added support for Django 5.2 and Django 6.0 7 | * Dropped support for Django 5.0 and Django 5.1 8 | 9 | 1.4.0 (2025-03-16) 10 | ------------------ 11 | * Added support for Django 5.1.7 which made a backwards incompatible change by 12 | removing the `single_object` kwarg from the `LogEntry.log_actions()` method 13 | (`#36217 `_). 14 | 15 | 1.3.0 (2024-08-10) 16 | ------------------ 17 | * Added support for Django 5.1 18 | * Dropped support for Django 3.2 19 | 20 | 1.2.0 (2024-03-03) 21 | ------------------ 22 | * Added option to ignore logs where no changes were made. 23 | * Added link to log entry user in admin list of log entries. 24 | 25 | 1.1.0 (2023-12-18) 26 | ------------------ 27 | * Only display content types to filter that actually have a related log entry. 28 | * Added support for Django 4.2 and 5.0 29 | * Dropped support for Django 2.2, 3.0 and 3.1 30 | 31 | 1.0.2 (2021-04-24) 32 | ------------------ 33 | * Added support for Django 3.2 and Python 3.9 34 | * Dropped support for Django 2.1 and Python 3.5 35 | 36 | 1.0.1 (2020-08-16) 37 | ------------------ 38 | * Added support for Django 3.1 39 | 40 | 1.0.0 (2020-03-03) 41 | ------------------ 42 | * Initial release 43 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | 4 | def pytest_configure(): 5 | settings.configure( 6 | SECRET_KEY="TEST_KEY", 7 | ROOT_URLCONF="tests.urls", 8 | INSTALLED_APPS=[ 9 | "django.contrib.sessions", 10 | "django.contrib.contenttypes", 11 | "django.contrib.auth", 12 | "django.contrib.admin", 13 | "django_admin_logs", 14 | ], 15 | MIDDLEWARE_CLASSES=[ 16 | "django.contrib.sessions.middleware.SessionMiddleware", 17 | "django.contrib.auth.middleware.AuthenticationMiddleware", 18 | ], 19 | MIDDLEWARE=[ 20 | "django.contrib.sessions.middleware.SessionMiddleware", 21 | "django.contrib.auth.middleware.AuthenticationMiddleware", 22 | ], 23 | TEMPLATES=[ 24 | { 25 | "BACKEND": "django.template.backends.django.DjangoTemplates", 26 | "APP_DIRS": True, 27 | "OPTIONS": { 28 | "context_processors": [ 29 | "django.contrib.auth.context_processors.auth", 30 | ], 31 | }, 32 | } 33 | ], 34 | DATABASES={ 35 | "default": { 36 | "ENGINE": "django.db.backends.sqlite3", 37 | "NAME": ":memory:", 38 | }, 39 | }, 40 | ) 41 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.0"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "django-admin-logs" 7 | description = "View, delete or disable Django admin log entries." 8 | version = "1.5.0" 9 | authors = [ 10 | { name="Adam Radwon" }, 11 | ] 12 | license = { text = "MIT License" } 13 | readme = "README.rst" 14 | keywords = ["django", "admin", "logs"] 15 | requires-python = ">=3.8" 16 | dependencies = [ 17 | "Django>=4.2" 18 | ] 19 | classifiers = [ 20 | "Development Status :: 5 - Production/Stable", 21 | "Environment :: Web Environment", 22 | "Framework :: Django", 23 | "Intended Audience :: Developers", 24 | "License :: OSI Approved :: MIT License", 25 | "Operating System :: OS Independent", 26 | "Programming Language :: Python :: 3", 27 | "Topic :: Software Development :: Libraries :: Python Modules", 28 | ] 29 | 30 | [project.urls] 31 | Homepage = "https://github.com/radwon/django-admin-logs" 32 | 33 | [project.optional-dependencies] 34 | dev = [ 35 | "black", 36 | "build", 37 | "coverage", 38 | "Django", 39 | "pre-commit", 40 | "pytest", 41 | "pytest-cov", 42 | "pytest-django", 43 | "ruff", 44 | "tox", 45 | "twine", 46 | ] 47 | 48 | [tool.coverage.report] 49 | fail_under = 100 50 | 51 | [tool.coverage.run] 52 | omit = ["*/tests/*", "*__init__.py"] 53 | 54 | [tool.ruff] 55 | line-length = 88 # Same as Black 56 | 57 | [tool.ruff.lint] 58 | ignore = [ 59 | "E501", # line length violations (handled by Black) 60 | ] 61 | select = [ 62 | "B", # flake8-bugbear rules 63 | "E", # pycodestyle errors 64 | "F", # pyflakes 65 | "I", # isort (import sorting) 66 | "UP", # pyupgrade 67 | "W", # pycodestyle warnings 68 | ] 69 | 70 | [tool.pytest.ini_options] 71 | addopts = "--cov=django_admin_logs --cov-report=html" 72 | -------------------------------------------------------------------------------- /django_admin_logs/models.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django Admin Logs - Models. 3 | """ 4 | 5 | import django 6 | from django.contrib.admin import models 7 | 8 | 9 | class LogEntryManager(models.LogEntryManager): 10 | """The default LogEntry Model Manager.""" 11 | 12 | def __init__(self, model=None): 13 | super().__init__() 14 | self.model = model 15 | if django.VERSION >= (5, 1) and django.VERSION < (6, 0): # pragma: no cover 16 | # Prevent RemovedInDjango60Warning by reverting deprecated method 17 | type(self).log_action = models.LogEntryManager.log_action 18 | 19 | 20 | class ChangedLogEntryManager(LogEntryManager): 21 | """A LogEntry Model Manager that ignores logs with no changes.""" 22 | 23 | # Deprecated in Django 5.1 to be removed in Django 6.0 24 | def log_action( 25 | self, 26 | user_id, 27 | content_type_id, 28 | object_id, 29 | object_repr, 30 | action_flag, 31 | change_message="", 32 | ): # pragma: no cover 33 | # Check whether this is a log with no changes that should be ignored 34 | if action_flag == models.CHANGE and not change_message: 35 | return None 36 | else: # Log as normal 37 | return super().log_action( 38 | user_id, 39 | content_type_id, 40 | object_id, 41 | object_repr, 42 | action_flag, 43 | change_message, 44 | ) 45 | 46 | def log_actions(self, user_id, queryset, action_flag, change_message="", **kwargs): 47 | # Check whether this is a log with no changes that should be ignored 48 | if action_flag == models.CHANGE and not change_message: 49 | return None 50 | else: # Log as normal 51 | return super().log_actions( 52 | user_id, queryset, action_flag, change_message, **kwargs 53 | ) 54 | 55 | 56 | class NoLogEntryManager(LogEntryManager): 57 | """A No LogEntry Model Manager.""" 58 | 59 | # Deprecated in Django 5.1 to be removed in Django 6.0 60 | def log_action(self, *args, **kwargs): # pragma: no cover 61 | return None 62 | 63 | def log_actions(self, *args, **kwargs): 64 | # No logging 65 | return None 66 | 67 | def get_queryset(self): 68 | # No queries 69 | return super().get_queryset().none() 70 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ================= 2 | Django Admin Logs 3 | ================= 4 | 5 | .. image:: https://img.shields.io/pypi/v/django-admin-logs.svg 6 | :target: https://pypi.python.org/pypi/django-admin-logs 7 | 8 | .. image:: https://img.shields.io/codecov/c/github/radwon/django-admin-logs.svg 9 | :target: https://codecov.io/gh/radwon/django-admin-logs 10 | 11 | Log entries are automatically created by the Django framework whenever a user 12 | adds, changes or deletes objects through the admin interface. 13 | 14 | **Django Admin Logs** is a package that allows you to either view the admin 15 | log entries from within the admin interface, or to disable them entirely. 16 | 17 | 18 | Requirements 19 | ============ 20 | 21 | * Python 3.8+ 22 | * Django 4.2+ 23 | 24 | 25 | Installation 26 | ============ 27 | 28 | Install the package from PyPI: 29 | 30 | .. code-block:: bash 31 | 32 | pip install django-admin-logs 33 | 34 | Then add it to your ``INSTALLED_APPS`` in the ``settings`` file: 35 | 36 | .. code-block:: python 37 | 38 | INSTALLED_APPS = ( 39 | ... 40 | 'django_admin_logs', 41 | ... 42 | ) 43 | 44 | 45 | Configuration 46 | ============= 47 | 48 | By default, **Django Admin Logs** enables log entries to be viewed from within 49 | the admin interface but does not allow them to be deleted. Either of these 50 | options can be configured by adding the following to your ``settings`` file. 51 | 52 | .. code-block:: python 53 | 54 | DJANGO_ADMIN_LOGS_DELETABLE = True 55 | 56 | This allows a superuser, or any staff user with the delete_logentry 57 | permission, to delete log entries from within the admin interface. 58 | 59 | .. code-block:: python 60 | 61 | DJANGO_ADMIN_LOGS_ENABLED = False 62 | 63 | This disables admin log entries so that they are no longer created by the 64 | Django framework or viewable from within the admin interface. 65 | 66 | By default, Django creates log entries with the message "No fields changed" 67 | when an unchanged object is saved in the admin interface. To prevent such log 68 | entries from being created use the following setting: 69 | 70 | .. code-block:: python 71 | 72 | DJANGO_ADMIN_LOGS_IGNORE_UNCHANGED = True 73 | 74 | 75 | Development 76 | =========== 77 | 78 | From the local project directory, activate the virtual environment and install the development requirements: 79 | 80 | .. code-block:: bash 81 | 82 | pip install -e .[dev] 83 | 84 | To run tests for the installed version of Python and Django using pytest: 85 | 86 | .. code-block:: bash 87 | 88 | pytest 89 | 90 | To run tests for all supported Python and Django versions using tox: 91 | 92 | .. code-block:: bash 93 | 94 | tox 95 | 96 | To run tests for specific versions e.g. Python 3.12 and Django 5.2: 97 | 98 | .. code-block:: bash 99 | 100 | tox -e py312-django52 101 | -------------------------------------------------------------------------------- /django_admin_logs/admin.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django Admin Logs - Model Admin. 3 | """ 4 | 5 | from django.contrib import admin 6 | from django.contrib.admin.models import LogEntry 7 | from django.urls import reverse 8 | from django.utils.html import format_html 9 | 10 | from .settings import DJANGO_ADMIN_LOGS_DELETABLE, DJANGO_ADMIN_LOGS_ENABLED 11 | 12 | 13 | class LogEntryAdmin(admin.ModelAdmin): 14 | """Log Entry admin interface.""" 15 | 16 | date_hierarchy = "action_time" 17 | fields = ( 18 | "action_time", 19 | "user", 20 | "content_type", 21 | "object_id", 22 | "object_repr", 23 | "action_flag", 24 | "change_message", 25 | ) 26 | list_display = ( 27 | "action_time", 28 | "user_link", 29 | "action_message", 30 | "content_type", 31 | "object_link", 32 | ) 33 | list_filter = ( 34 | "action_time", 35 | "action_flag", 36 | ("content_type", admin.RelatedOnlyFieldListFilter), 37 | ("user", admin.RelatedOnlyFieldListFilter), 38 | ) 39 | search_fields = ( 40 | "object_repr", 41 | "change_message", 42 | ) 43 | 44 | @admin.display(description="user") 45 | def user_link(self, obj): 46 | """Returns the admin change link to the user object.""" 47 | admin_url = reverse( 48 | f"admin:{obj.user._meta.app_label}_{obj.user._meta.model_name}_change", 49 | args=[obj.user.pk], 50 | ) 51 | return format_html('{}', admin_url, obj.user) 52 | 53 | @admin.display(description="object") 54 | def object_link(self, obj): 55 | """Returns the admin link to the log entry object if it exists.""" 56 | admin_url = None if obj.is_deletion() else obj.get_admin_url() 57 | if admin_url: 58 | return format_html('{}', admin_url, obj.object_repr) 59 | else: 60 | return obj.object_repr 61 | 62 | @admin.display(description="action") 63 | def action_message(self, obj): 64 | """ 65 | Returns the action message. 66 | Note: this handles deletions which don't return a change message. 67 | """ 68 | change_message = obj.get_change_message() 69 | # If there is no change message then use the action flag label 70 | if not change_message: 71 | change_message = f"{obj.get_action_flag_display()}." 72 | return change_message 73 | 74 | def get_queryset(self, request): 75 | return super().get_queryset(request).prefetch_related("content_type") 76 | 77 | def has_add_permission(self, request): 78 | """Log entries cannot be added manually.""" 79 | return False 80 | 81 | def has_change_permission(self, request, obj=None): 82 | """Log entries cannot be changed.""" 83 | return False 84 | 85 | def has_delete_permission(self, request, obj=None): 86 | """Log entries can only be deleted when the setting is enabled.""" 87 | return DJANGO_ADMIN_LOGS_DELETABLE and super().has_delete_permission( 88 | request, obj 89 | ) 90 | 91 | # Prevent changes to log entries creating their own log entries! 92 | def log_addition(self, request, obj, message): 93 | pass 94 | 95 | def log_change(self, request, obj, message): 96 | pass 97 | 98 | # Deprecated in Django 5.1 to be removed in Django 6.0 99 | def log_deletion(self, request, obj, object_repr): # pragma: no cover 100 | pass 101 | 102 | def log_deletions(self, request, queryset): 103 | pass 104 | 105 | 106 | # Register the LogEntry admin if enabled and not already registered 107 | if DJANGO_ADMIN_LOGS_ENABLED and not admin.site.is_registered(LogEntry): 108 | admin.site.register(LogEntry, LogEntryAdmin) 109 | -------------------------------------------------------------------------------- /tests/test_models.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django Admin Logs - Test Models. 3 | """ 4 | 5 | from unittest import mock 6 | 7 | import django 8 | from django.apps import apps 9 | from django.contrib.admin.models import CHANGE, LogEntry 10 | from django.contrib.auth import get_user_model 11 | from django.contrib.contenttypes.models import ContentType 12 | from django.test import TestCase 13 | 14 | User = get_user_model() 15 | 16 | 17 | class LogEntryManagerTest(TestCase): 18 | """Tests the LogEntry Model Manager.""" 19 | 20 | @classmethod 21 | def setUpTestData(cls): 22 | """Set up test data once for all tests.""" 23 | cls.user = User.objects.create_user("user", "user@localhost", "password") 24 | cls.content_type = ContentType.objects.get_for_model(User) 25 | 26 | @mock.patch("django_admin_logs.settings.DJANGO_ADMIN_LOGS_ENABLED", True) 27 | def test_log_actions(self): 28 | """Test that a log entry is created when admin logs are enabled.""" 29 | # Re-run the app config ready() method to use the test settings 30 | apps.get_app_config("django_admin_logs").ready() 31 | # Ensure there are no log entries yet 32 | self.assertEqual(LogEntry.objects.count(), 0) 33 | # Create a log entry when admin logs are enabled 34 | if django.VERSION < (5, 1): 35 | log_entry = LogEntry.objects.log_action( 36 | self.user.pk, 37 | self.content_type.pk, 38 | self.user.pk, 39 | repr(self.user), 40 | CHANGE, 41 | ) 42 | else: 43 | log_entry = LogEntry.objects.log_actions( 44 | self.user.pk, 45 | [self.user], 46 | CHANGE, 47 | )[0] 48 | # Ensure the log entry was created for the action 49 | self.assertEqual(log_entry, LogEntry.objects.first()) 50 | self.assertEqual(LogEntry.objects.count(), 1) 51 | 52 | 53 | class ChangedLogEntryManagerTest(TestCase): 54 | """Tests the LogEntry Model Manager that ignores logs with no changes.""" 55 | 56 | @classmethod 57 | def setUpTestData(cls): 58 | """Set up test data once for all tests.""" 59 | cls.user = User.objects.create_user("user", "user@localhost", "password") 60 | cls.content_type = ContentType.objects.get_for_model(User) 61 | 62 | @mock.patch("django_admin_logs.settings.DJANGO_ADMIN_LOGS_IGNORE_UNCHANGED", True) 63 | def test_unchanged_log_actions(self): 64 | """Test that logs are ignored if there are no changes.""" 65 | # Re-run the app config ready() method to use the test settings 66 | apps.get_app_config("django_admin_logs").ready() 67 | # Ensure there are no log entries yet 68 | self.assertEqual(LogEntry.objects.count(), 0) 69 | # Attempt to create a log entry with no changes (in the message) 70 | if django.VERSION < (5, 1): 71 | log_entry = LogEntry.objects.log_action( 72 | self.user.pk, 73 | self.content_type.pk, 74 | self.user.pk, 75 | repr(self.user), 76 | CHANGE, 77 | "", # No change message 78 | ) 79 | else: 80 | log_entry = LogEntry.objects.log_actions( 81 | self.user.pk, [self.user], CHANGE, "" 82 | ) 83 | # Ensure there was no log entry created for the action 84 | self.assertEqual(log_entry, None) 85 | self.assertEqual(LogEntry.objects.count(), 0) 86 | # Ensure a log entry with a change message is still created 87 | if django.VERSION < (5, 1): 88 | log_entry = LogEntry.objects.log_action( 89 | self.user.pk, 90 | self.content_type.pk, 91 | self.user.pk, 92 | repr(self.user), 93 | CHANGE, 94 | "Changed user", 95 | ) 96 | else: 97 | log_entry = LogEntry.objects.log_actions( 98 | self.user.pk, [self.user], CHANGE, "Changed user" 99 | )[0] 100 | self.assertEqual(log_entry, LogEntry.objects.first()) 101 | self.assertEqual(LogEntry.objects.count(), 1) 102 | 103 | 104 | class NoLogEntryManagerTest(TestCase): 105 | """Tests the No LogEntry Model Manager.""" 106 | 107 | @classmethod 108 | def setUpTestData(cls): 109 | """Set up test data once for all tests.""" 110 | cls.user = User.objects.create_user("user", "user@localhost", "password") 111 | cls.content_type = ContentType.objects.get_for_model(User) 112 | 113 | @mock.patch("django_admin_logs.settings.DJANGO_ADMIN_LOGS_ENABLED", False) 114 | def test_no_log_actions(self): 115 | """Test that a log entry is not created when admin logs are disabled.""" 116 | # Re-run the app config ready() method to use the test settings 117 | apps.get_app_config("django_admin_logs").ready() 118 | # Ensure there are no log entries yet 119 | self.assertEqual(LogEntry.objects.count(), 0) 120 | # Attempt to create a log entry when admin logs are disabled 121 | if django.VERSION < (5, 1): 122 | log_entry = LogEntry.objects.log_action( 123 | self.user.pk, 124 | self.content_type.pk, 125 | self.user.pk, 126 | repr(self.user), 127 | CHANGE, 128 | ) 129 | else: 130 | log_entry = LogEntry.objects.log_actions(self.user.pk, [self.user], CHANGE) 131 | # Ensure there was no log entry created for the action 132 | self.assertEqual(log_entry, None) 133 | self.assertEqual(LogEntry.objects.count(), 0) 134 | -------------------------------------------------------------------------------- /tests/test_admin.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django Admin Logs - Test Admin. 3 | """ 4 | 5 | from unittest import mock 6 | 7 | import django 8 | from django.contrib.admin.models import ADDITION, DELETION, LogEntry 9 | from django.contrib.admin.sites import AdminSite 10 | from django.contrib.auth import get_user_model 11 | from django.contrib.contenttypes.models import ContentType 12 | from django.test import RequestFactory, TestCase 13 | from django.urls import reverse 14 | 15 | from django_admin_logs.admin import LogEntryAdmin 16 | 17 | User = get_user_model() 18 | 19 | 20 | class LogEntryAdminTest(TestCase): 21 | """Tests the LogEntry Model Admin.""" 22 | 23 | @classmethod 24 | def setUpClass(cls): 25 | """Set up once for all tests.""" 26 | super().setUpClass() 27 | cls.logentry_admin = LogEntryAdmin(LogEntry, AdminSite()) 28 | cls.admin_user = User.objects.create_superuser( 29 | "admin", 30 | "admin@localhost", 31 | "password", 32 | ) 33 | cls.staff_user = User.objects.create_user( 34 | "staff", 35 | "staff@localhost", 36 | "password", 37 | is_staff=True, 38 | ) 39 | 40 | def setUp(self): 41 | """Set up before each test.""" 42 | self.request = RequestFactory().get("/admin/") 43 | 44 | def test_user_link(self): 45 | """Test the admin change link to the user object.""" 46 | log_entry = LogEntry( 47 | user=self.admin_user, 48 | action_flag=ADDITION, 49 | content_type_id=ContentType.objects.get_for_model(User).id, 50 | object_id=self.admin_user.id, 51 | object_repr=str(self.admin_user), 52 | ) 53 | admin_url = reverse( 54 | f"admin:{User._meta.app_label}_{User._meta.model_name}_change", 55 | args=[log_entry.user.pk], 56 | ) 57 | self.assertEqual( 58 | self.logentry_admin.user_link(log_entry), 59 | f'{log_entry.user}', 60 | ) 61 | 62 | def test_object_link(self): 63 | """Test the admin link to the log entry object.""" 64 | log_entry = LogEntry( 65 | user=self.admin_user, 66 | action_flag=ADDITION, 67 | content_type_id=ContentType.objects.get_for_model(User).id, 68 | object_id=self.admin_user.id, 69 | object_repr=str(self.admin_user), 70 | ) 71 | self.assertEqual( 72 | self.logentry_admin.object_link(log_entry), 73 | f'{log_entry.object_repr}', 74 | ) 75 | # Test that a DELETION log entry returns object without a link 76 | log_entry.action_flag = DELETION 77 | self.assertEqual( 78 | self.logentry_admin.object_link(log_entry), log_entry.object_repr 79 | ) 80 | 81 | def test_action_message(self): 82 | """Test getting the action message.""" 83 | log_entry = LogEntry( 84 | user=self.admin_user, 85 | action_flag=ADDITION, 86 | content_type_id=ContentType.objects.get_for_model(User).id, 87 | object_id=self.admin_user.id, 88 | object_repr=str(self.admin_user), 89 | ) 90 | # Ensure a log entry without a change message uses the action flag label 91 | self.assertEqual( 92 | self.logentry_admin.action_message(log_entry), 93 | f"{log_entry.get_action_flag_display()}.", 94 | ) 95 | # Ensure a log entry with a change message is used for the action message 96 | change_message = "This is a change message" 97 | log_entry.change_message = change_message 98 | self.assertEqual(self.logentry_admin.action_message(log_entry), change_message) 99 | 100 | def test_has_view_permission(self): 101 | """Test that only users with permission can view the admin logs.""" 102 | # Super users have all permissions 103 | self.request.user = self.admin_user 104 | self.assertTrue(self.logentry_admin.has_view_permission(self.request)) 105 | # Other users don't have view permission unless explicitly granted 106 | self.request.user = self.staff_user 107 | self.assertFalse(self.logentry_admin.has_view_permission(self.request)) 108 | 109 | def test_has_add_permission(self): 110 | """Test that users cannot add logs.""" 111 | self.request.user = self.admin_user 112 | self.assertFalse(self.logentry_admin.has_add_permission(self.request)) 113 | 114 | def test_has_change_permission(self): 115 | """Test that users cannot change logs.""" 116 | self.request.user = self.admin_user 117 | self.assertFalse(self.logentry_admin.has_change_permission(self.request)) 118 | 119 | def test_has_delete_permission(self): 120 | """Test that logs can only be deleted when the setting is enabled.""" 121 | # With the delete setting disabled super users cannot delete logs 122 | with mock.patch( 123 | LogEntryAdmin.__module__ + ".DJANGO_ADMIN_LOGS_DELETABLE", False 124 | ): 125 | self.request.user = self.admin_user 126 | self.assertFalse(self.logentry_admin.has_delete_permission(self.request)) 127 | # With the delete setting enabled super users can delete logs 128 | with mock.patch( 129 | LogEntryAdmin.__module__ + ".DJANGO_ADMIN_LOGS_DELETABLE", True 130 | ): 131 | self.assertTrue(self.logentry_admin.has_delete_permission(self.request)) 132 | # Ensure other users still can't delete logs without permission 133 | self.request.user = self.staff_user 134 | self.assertFalse(self.logentry_admin.has_delete_permission(self.request)) 135 | 136 | def test_no_log_actions(self): 137 | """Test that no actions are created for changes to log entries.""" 138 | # Ensure no actions are created for adding log entries 139 | self.logentry_admin.log_addition(self.request, self.admin_user, "Added") 140 | query_count = self.logentry_admin.get_queryset(self.request).count() 141 | self.assertEqual(query_count, 0) 142 | # Ensure no actions are created for changing log entries 143 | self.logentry_admin.log_change(self.request, self.admin_user, "Changed") 144 | query_count = self.logentry_admin.get_queryset(self.request).count() 145 | self.assertEqual(query_count, 0) 146 | # Ensure no actions are created for deleting log entries 147 | if django.VERSION < (5, 1): 148 | self.logentry_admin.log_deletion( 149 | self.request, self.admin_user, str(self.admin_user) 150 | ) 151 | else: 152 | self.logentry_admin.log_deletions(self.request, [self.admin_user]) 153 | query_count = self.logentry_admin.get_queryset(self.request).count() 154 | self.assertEqual(query_count, 0) 155 | --------------------------------------------------------------------------------