diff --git a/constance/admin.py b/constance/admin.py index c2d7c12..712f8a9 100644 --- a/constance/admin.py +++ b/constance/admin.py @@ -41,6 +41,14 @@ class ConstanceAdmin(admin.ModelAdmin): return [ path("", self.admin_site.admin_view(self.changelist_view), name=f"{info}_changelist"), path("", self.admin_site.admin_view(self.changelist_view), name=f"{info}_add"), + # Redirect /change/ to the changelist so that "Recent actions" links in the admin index point + # somewhere useful. + path( + "/change/", + lambda request, object_id: HttpResponseRedirect("../../"), + name=f"{info}_change", + ), + path("history/", self.admin_site.admin_view(self.history_view), name=f"{info}_history"), ] def get_config_value(self, name, options, form, initial): @@ -160,6 +168,47 @@ class ConstanceAdmin(admin.ModelAdmin): request.current_app = self.admin_site.name return TemplateResponse(request, self.change_list_template, context) + def history_view(self, request, object_id=None, extra_context=None): + """Display the change history for constance config values.""" + from django.contrib.admin.views.main import PAGE_VAR + + if not self.has_view_or_change_permission(request): + raise PermissionDenied + + ct = ContentType.objects.get_for_model(self.model) + action_list = ( + LogEntry.objects.filter( + content_type=ct, + object_id="Config", + ) + .select_related() + .order_by("-action_time") + ) + + paginator = self.get_paginator(request, action_list, 100) + page_number = request.GET.get(PAGE_VAR, 1) + page_obj = paginator.get_page(page_number) + page_range = paginator.get_elided_page_range(page_obj.number) + + context = { + **self.admin_site.each_context(request), + "title": _("Change history: %s") % self.model._meta.verbose_name_plural.capitalize(), + "action_list": page_obj, + "page_range": page_range, + "page_var": PAGE_VAR, + "pagination_required": paginator.count > 100, + "opts": self.model._meta, + "app_label": "constance", + **(extra_context or {}), + } + + request.current_app = self.admin_site.name + return TemplateResponse( + request, + "admin/constance/config_history.html", + context, + ) + def _log_config_change(self, request, changed_fields): """ Create a Django admin LogEntry recording which config fields were changed. diff --git a/constance/templates/admin/constance/change_list.html b/constance/templates/admin/constance/change_list.html index 72b834a..fce2bfb 100644 --- a/constance/templates/admin/constance/change_list.html +++ b/constance/templates/admin/constance/change_list.html @@ -23,6 +23,9 @@ {% block bodyclass %}{{ block.super }} change-list{% endblock %} {% block content %} +
{% csrf_token %} diff --git a/constance/templates/admin/constance/config_history.html b/constance/templates/admin/constance/config_history.html new file mode 100644 index 0000000..078fff9 --- /dev/null +++ b/constance/templates/admin/constance/config_history.html @@ -0,0 +1,55 @@ +{% extends "admin/base_site.html" %} +{% load i18n %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} +
+
+ +{% if action_list %} + + + + + + + + + + {% for action in action_list %} + + + + + + {% endfor %} + +
{% translate 'Date/time' %}{% translate 'User' %}{% translate 'Action' %}
{{ action.action_time|date:"DATETIME_FORMAT" }}{{ action.user.get_username }}{% if action.user.get_full_name %} ({{ action.user.get_full_name }}){% endif %}{{ action.get_change_message }}
+

+ {% if pagination_required %} + {% for i in page_range %} + {% if i == action_list.paginator.ELLIPSIS %} + {{ action_list.paginator.ELLIPSIS }} + {% elif i == action_list.number %} + {{ i }} + {% else %} + {{ i }} + {% endif %} + {% endfor %} + {% endif %} + {{ action_list.paginator.count }} {% blocktranslate count counter=action_list.paginator.count %}entry{% plural %}entries{% endblocktranslate %} +

+{% else %} +

{% translate "This object doesn't have a change history." %}

+{% endif %} +
+
+{% endblock %} diff --git a/tests/test_admin.py b/tests/test_admin.py index 097279d..05d35ac 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -401,6 +401,78 @@ class TestAdmin(TestCase): self.assertIsInstance(response, HttpResponseRedirect) self.assertEqual(LogEntry.objects.count(), initial_count) + def test_history_view(self): + """Test that the history view renders and shows LogEntry records.""" + from django.contrib.admin.models import CHANGE + from django.contrib.admin.models import LogEntry + from django.contrib.contenttypes.models import ContentType + + ct = ContentType.objects.get_for_model(self.model) + LogEntry.objects.create( + user_id=self.superuser.pk, + content_type_id=ct.pk, + object_id="Config", + object_repr="Config", + action_flag=CHANGE, + change_message=json.dumps([{"changed": {"fields": ["INT_VALUE"]}}]), + ) + + request = self.rf.get("/admin/constance/config/history/") + request.user = self.superuser + response = self.options.history_view(request) + self.assertEqual(response.status_code, 200) + response.render() + content = response.content.decode() + self.assertIn("INT_VALUE", content) + self.assertIn("History", content) + + def test_history_view_empty(self): + """Test that the history view renders correctly with no entries.""" + request = self.rf.get("/admin/constance/config/history/") + request.user = self.superuser + response = self.options.history_view(request) + self.assertEqual(response.status_code, 200) + response.render() + content = response.content.decode() + self.assertIn("0", content) + + def test_history_view_permission_denied(self): + """Test that the history view denies access to users without permission.""" + from django.contrib.auth.models import User + + unprivileged = User.objects.create_user("noperm", "noperm", "c@c.cz") + request = self.rf.get("/admin/constance/config/history/") + request.user = unprivileged + with self.assertRaises(PermissionDenied): + self.options.history_view(request) + + def test_changelist_has_history_link(self): + """Test that the changelist page contains a link to the history view.""" + request = self.rf.get("/admin/constance/config/") + request.user = self.superuser + response = self.options.changelist_view(request) + response.render() + content = response.content.decode() + self.assertIn('href="history/"', content) + self.assertIn("History", content) + + def test_change_url_redirects_to_changelist(self): + """Test that the change URL (used by 'Recent actions') redirects to the changelist.""" + from django.urls import reverse + + url = reverse("admin:constance_config_change", args=["Config"]) + self.assertIn("Config/change/", url) + request = self.rf.get(url) + request.user = self.superuser + + # The change URL is a simple lambda redirect, so invoke it via URL resolution. + from django.urls import resolve + + match = resolve(url) + response = match.func(request, object_id="Config") + self.assertEqual(response.status_code, 302) + self.assertEqual(response.url, "../../") + def test_labels(self): self.assertEqual(type(self.model._meta.label), str) self.assertEqual(type(self.model._meta.label_lower), str)