From eae9f10412d6f2068e22974bf841d49b03cea5d3 Mon Sep 17 00:00:00 2001 From: PhilippTh Date: Sun, 15 Mar 2026 23:44:42 +0100 Subject: [PATCH] Added Logentry creation --- constance/admin.py | 25 +++++++++++++- constance/forms.py | 9 +++++ tests/test_admin.py | 81 ++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 113 insertions(+), 2 deletions(-) diff --git a/constance/admin.py b/constance/admin.py index 188f32e..c2d7c12 100644 --- a/constance/admin.py +++ b/constance/admin.py @@ -9,7 +9,10 @@ from django import get_version from django.apps import apps from django.contrib import admin from django.contrib import messages +from django.contrib.admin.models import CHANGE +from django.contrib.admin.models import LogEntry from django.contrib.admin.options import csrf_protect_m +from django.contrib.contenttypes.models import ContentType from django.core.exceptions import PermissionDenied from django.http import HttpResponseRedirect from django.template.response import TemplateResponse @@ -94,7 +97,9 @@ class ConstanceAdmin(admin.ModelAdmin): if request.method == "POST" and request.user.has_perm("constance.change_config"): form = form_cls(data=request.POST, files=request.FILES, initial=initial, request=request) if form.is_valid(): - form.save() + changed_fields = form.save() + if changed_fields: + self._log_config_change(request, changed_fields) messages.add_message(request, messages.SUCCESS, _("Live settings updated successfully.")) return HttpResponseRedirect(".") messages.add_message(request, messages.ERROR, _("Failed to update live settings.")) @@ -155,6 +160,24 @@ class ConstanceAdmin(admin.ModelAdmin): request.current_app = self.admin_site.name return TemplateResponse(request, self.change_list_template, context) + def _log_config_change(self, request, changed_fields): + """ + Create a Django admin LogEntry recording which config fields were changed. + + Uses the standard Django JSON change_message format so that + LogEntry.get_change_message() can interpret it correctly. + """ + ct = ContentType.objects.get_for_model(self.model) + change_message = json.dumps([{"changed": {"fields": changed_fields}}]) + LogEntry.objects.create( + user_id=request.user.pk, + content_type_id=ct.pk, + object_id="Config", + object_repr="Config", + action_flag=CHANGE, + change_message=change_message, + ) + def has_add_permission(self, *args, **kwargs): return False diff --git a/constance/forms.py b/constance/forms.py index 321ab47..4975b99 100644 --- a/constance/forms.py +++ b/constance/forms.py @@ -124,10 +124,16 @@ class ConstanceForm(forms.Form): self.initial["version"] = version_hash.hexdigest() def save(self): + """ + Save changed config values to the backend. + + Returns a list of config field names that were actually modified. + """ for file_field in self.files: file = self.cleaned_data[file_field] self.cleaned_data[file_field] = default_storage.save(join(settings.FILE_ROOT, file.name), file) + changed_fields = [] for name in settings.CONFIG: current = getattr(config, name) new = self.cleaned_data[name] @@ -140,6 +146,9 @@ class ConstanceForm(forms.Form): if current != new: setattr(config, name, new) + changed_fields.append(name) + + return changed_fields def clean_version(self): value = self.cleaned_data["version"] diff --git a/tests/test_admin.py b/tests/test_admin.py index 4f25339..097279d 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -1,3 +1,4 @@ +import json from datetime import datetime from unittest import mock @@ -28,6 +29,11 @@ class TestAdmin(TestCase): self.normaluser.is_staff = True self.normaluser.save() self.options = admin.site._registry[self.model] + # Clear ContentType cache to avoid stale content_type_id references + # across tests wrapped in transactions. + from django.contrib.contenttypes.models import ContentType + + ContentType.objects.clear_cache() def test_changelist(self): self.client.login(username="admin", password="nimda") @@ -126,7 +132,7 @@ class TestAdmin(TestCase): }, ) @mock.patch("constance.settings.IGNORE_ADMIN_VERSION_CHECK", True) - @mock.patch("constance.forms.ConstanceForm.save", lambda _: None) + @mock.patch("constance.forms.ConstanceForm.save", lambda _: []) @mock.patch("constance.forms.ConstanceForm.is_valid", lambda _: True) def test_submit(self): """ @@ -322,6 +328,79 @@ class TestAdmin(TestCase): # Clean up FIELDS to avoid leaking into other tests FIELDS.pop("language_select", None) + @mock.patch("constance.settings.CONFIG_FIELDSETS", {"FieldSetOne": ("INT_VALUE", "STRING_VALUE")}) + @mock.patch( + "constance.settings.CONFIG", + { + "INT_VALUE": (1, "some int"), + "STRING_VALUE": ("Hello world", "greetings"), + }, + ) + @mock.patch("constance.settings.IGNORE_ADMIN_VERSION_CHECK", True) + @mock.patch("constance.forms.ConstanceForm.save", lambda _: ["INT_VALUE"]) + @mock.patch("constance.forms.ConstanceForm.is_valid", lambda _: True) + def test_log_entry_created_on_change(self): + """Test that a valid LogEntry is created when config values are changed.""" + from django.contrib.admin.models import CHANGE + from django.contrib.admin.models import LogEntry + + request = self.rf.post( + "/admin/constance/config/", + data={ + "INT_VALUE": "42", + "STRING_VALUE": "Hello world", + "version": "123", + }, + ) + request.user = self.superuser + request._dont_enforce_csrf_checks = True + + with mock.patch("django.contrib.messages.add_message"): + response = self.options.changelist_view(request, {}) + + self.assertIsInstance(response, HttpResponseRedirect) + log_entry = LogEntry.objects.latest("pk") + self.assertEqual(log_entry.user, self.superuser) + self.assertEqual(log_entry.action_flag, CHANGE) + self.assertEqual(log_entry.object_repr, "Config") + # Verify change_message uses Django's standard JSON format + # so that get_change_message() can render it correctly. + self.assertEqual( + log_entry.get_change_message(), + "Changed INT_VALUE.", + ) + + @mock.patch("constance.settings.CONFIG_FIELDSETS", {"FieldSetOne": ("INT_VALUE",)}) + @mock.patch( + "constance.settings.CONFIG", + { + "INT_VALUE": (1, "some int"), + }, + ) + @mock.patch("constance.settings.IGNORE_ADMIN_VERSION_CHECK", True) + @mock.patch("constance.forms.ConstanceForm.save", lambda _: []) + @mock.patch("constance.forms.ConstanceForm.is_valid", lambda _: True) + def test_no_log_entry_when_no_changes(self): + """Test that no LogEntry is created when the form is saved without any changes.""" + from django.contrib.admin.models import LogEntry + + initial_count = LogEntry.objects.count() + request = self.rf.post( + "/admin/constance/config/", + data={ + "INT_VALUE": "1", + "version": "123", + }, + ) + request.user = self.superuser + request._dont_enforce_csrf_checks = True + + with mock.patch("django.contrib.messages.add_message"): + response = self.options.changelist_view(request, {}) + + self.assertIsInstance(response, HttpResponseRedirect) + self.assertEqual(LogEntry.objects.count(), initial_count) + def test_labels(self): self.assertEqual(type(self.model._meta.label), str) self.assertEqual(type(self.model._meta.label_lower), str)