Compare commits

...

4 commits

Author SHA1 Message Date
dependabot[bot]
4a15df9f23
chore(ci): bump codecov/codecov-action in the github-actions group (#664)
Some checks failed
Docs / docs (push) Has been cancelled
Test / ruff-format (push) Has been cancelled
Test / ruff-lint (push) Has been cancelled
Test / build (3.10) (push) Has been cancelled
Test / build (3.11) (push) Has been cancelled
Test / build (3.12) (push) Has been cancelled
Test / build (3.13) (push) Has been cancelled
Test / build (3.14) (push) Has been cancelled
Test / build (3.8) (push) Has been cancelled
Test / build (3.9) (push) Has been cancelled
Bumps the github-actions group with 1 update: [codecov/codecov-action](https://github.com/codecov/codecov-action).


Updates `codecov/codecov-action` from 5 to 6
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/v5...v6)

---
updated-dependencies:
- dependency-name: codecov/codecov-action
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-02 13:19:06 -05:00
Philipp Thumfart
d91304e282
Added admin logentry (#662)
Some checks failed
Docs / docs (push) Has been cancelled
Test / ruff-format (push) Has been cancelled
Test / ruff-lint (push) Has been cancelled
Test / build (3.10) (push) Has been cancelled
Test / build (3.11) (push) Has been cancelled
Test / build (3.12) (push) Has been cancelled
Test / build (3.13) (push) Has been cancelled
Test / build (3.14) (push) Has been cancelled
Test / build (3.8) (push) Has been cancelled
Test / build (3.9) (push) Has been cancelled
* Added Logentry creation

* Added history view

* Used url template tag for history

* Minor cleanup
2026-03-17 14:45:31 +05:00
Alexandr Artemyev
9d82374dd6
Update project affiliation and code of conduct links (#663)
Related #660
2026-03-17 14:33:22 +05:00
Alexandr Artemyev
22f56b3064
Update Code of Conduct (#661)
Some checks are pending
Docs / docs (push) Waiting to run
Test / ruff-format (push) Waiting to run
Test / ruff-lint (push) Waiting to run
Test / build (3.10) (push) Waiting to run
Test / build (3.11) (push) Waiting to run
Test / build (3.12) (push) Waiting to run
Test / build (3.13) (push) Waiting to run
Test / build (3.14) (push) Waiting to run
Test / build (3.8) (push) Waiting to run
Test / build (3.9) (push) Waiting to run
Ref: #660
2026-03-16 09:38:32 -05:00
8 changed files with 291 additions and 50 deletions

View file

@ -49,6 +49,6 @@ jobs:
tox -v tox -v
- name: Upload coverage - name: Upload coverage
uses: codecov/codecov-action@v5 uses: codecov/codecov-action@v6
with: with:
name: Python ${{ matrix.python-version }} name: Python ${{ matrix.python-version }}

View file

@ -1,46 +1,3 @@
# Code of Conduct # Django Constance Code of Conduct
As contributors and maintainers of the Jazzband projects, and in the interest of The django-constance project utilizes the [Django Commons Code of Conduct](https://github.com/django-commons/membership/blob/main/CODE_OF_CONDUCT.md).
fostering an open and welcoming community, we pledge to respect all people who
contribute through reporting issues, posting feature requests, updating documentation,
submitting pull requests or patches, and other activities.
We are committed to making participation in the Jazzband a harassment-free experience
for everyone, regardless of the level of experience, gender, gender identity and
expression, sexual orientation, disability, personal appearance, body size, race,
ethnicity, age, religion, or nationality.
Examples of unacceptable behavior by participants include:
- The use of sexualized language or imagery
- Personal attacks
- Trolling or insulting/derogatory comments
- Public or private harassment
- Publishing other's private information, such as physical or electronic addresses,
without explicit permission
- Other unethical or unprofessional conduct
The Jazzband roadies have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are not
aligned to this Code of Conduct, or to ban temporarily or permanently any contributor
for other behaviors that they deem inappropriate, threatening, offensive, or harmful.
By adopting this Code of Conduct, the roadies commit themselves to fairly and
consistently applying these principles to every aspect of managing the jazzband
projects. Roadies who do not follow or enforce the Code of Conduct may be permanently
removed from the Jazzband roadies.
This code of conduct applies both within project spaces and in public spaces when an
individual is representing the project or its community.
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by
contacting the roadies at `roadies@jazzband.co`. All complaints will be reviewed and
investigated and will result in a response that is deemed necessary and appropriate to
the circumstances. Roadies are obligated to maintain confidentiality with regard to the
reporter of an incident.
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version
1.3.0, available at [https://contributor-covenant.org/version/1/3/0/][version]
[homepage]: https://contributor-covenant.org
[version]: https://contributor-covenant.org/version/1/3/0/

View file

@ -1,3 +1,3 @@
[![Jazzband](https://jazzband.co/static/img/jazzband.svg)](https://jazzband.co/) # Contributing to Django Constance
This is a [Jazzband](https://jazzband.co/) project. By contributing you agree to abide by the [Contributor Code of Conduct](https://jazzband.co/docs/conduct) and follow the [guidelines](https://jazzband.co/docs/guidelines). This is a [Django Commons](https://github.com/django-commons/) project. By contributing you agree to abide by the [Contributor Code of Conduct](https://github.com/django-commons/membership/blob/main/CODE_OF_CONDUCT.md).

View file

@ -9,7 +9,11 @@ from django import get_version
from django.apps import apps from django.apps import apps
from django.contrib import admin from django.contrib import admin
from django.contrib import messages 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.admin.options import csrf_protect_m
from django.contrib.admin.views.main import PAGE_VAR
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.http import HttpResponseRedirect from django.http import HttpResponseRedirect
from django.template.response import TemplateResponse from django.template.response import TemplateResponse
@ -38,6 +42,15 @@ class ConstanceAdmin(admin.ModelAdmin):
return [ 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}_changelist"),
path("", self.admin_site.admin_view(self.changelist_view), name=f"{info}_add"), path("", self.admin_site.admin_view(self.changelist_view), name=f"{info}_add"),
# Redirect <object_id>/change/ to the changelist so that "Recent actions" links in the admin index
# point somewhere useful. The relative "../../" resolves to the constance changelist because the
# full path is <app>/<model>/<object_id>/change/ and two levels up lands on <app>/<model>/.
path(
"<path:object_id>/change/",
self.admin_site.admin_view(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): def get_config_value(self, name, options, form, initial):
@ -94,7 +107,9 @@ class ConstanceAdmin(admin.ModelAdmin):
if request.method == "POST" and request.user.has_perm("constance.change_config"): 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) form = form_cls(data=request.POST, files=request.FILES, initial=initial, request=request)
if form.is_valid(): 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.")) messages.add_message(request, messages.SUCCESS, _("Live settings updated successfully."))
return HttpResponseRedirect(".") return HttpResponseRedirect(".")
messages.add_message(request, messages.ERROR, _("Failed to update live settings.")) messages.add_message(request, messages.ERROR, _("Failed to update live settings."))
@ -155,12 +170,74 @@ class ConstanceAdmin(admin.ModelAdmin):
request.current_app = self.admin_site.name request.current_app = self.admin_site.name
return TemplateResponse(request, self.change_list_template, context) 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."""
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.
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): def has_add_permission(self, *args, **kwargs):
return False return False
def has_delete_permission(self, *args, **kwargs): def has_delete_permission(self, *args, **kwargs):
return False return False
def has_view_permission(self, request, obj=None):
if settings.SUPERUSER_ONLY:
return request.user.is_superuser
return super().has_view_permission(request, obj)
def has_change_permission(self, request, obj=None): def has_change_permission(self, request, obj=None):
if settings.SUPERUSER_ONLY: if settings.SUPERUSER_ONLY:
return request.user.is_superuser return request.user.is_superuser

View file

@ -124,10 +124,16 @@ class ConstanceForm(forms.Form):
self.initial["version"] = version_hash.hexdigest() self.initial["version"] = version_hash.hexdigest()
def save(self): 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: for file_field in self.files:
file = self.cleaned_data[file_field] file = self.cleaned_data[file_field]
self.cleaned_data[file_field] = default_storage.save(join(settings.FILE_ROOT, file.name), file) self.cleaned_data[file_field] = default_storage.save(join(settings.FILE_ROOT, file.name), file)
changed_fields = []
for name in settings.CONFIG: for name in settings.CONFIG:
current = getattr(config, name) current = getattr(config, name)
new = self.cleaned_data[name] new = self.cleaned_data[name]
@ -140,6 +146,9 @@ class ConstanceForm(forms.Form):
if current != new: if current != new:
setattr(config, name, new) setattr(config, name, new)
changed_fields.append(name)
return changed_fields
def clean_version(self): def clean_version(self):
value = self.cleaned_data["version"] value = self.cleaned_data["version"]

View file

@ -23,6 +23,9 @@
{% block bodyclass %}{{ block.super }} change-list{% endblock %} {% block bodyclass %}{{ block.super }} change-list{% endblock %}
{% block content %} {% block content %}
<ul class="object-tools">
<li><a href="{% url 'admin:constance_config_history' %}" class="historylink">{% translate 'History' %}</a></li>
</ul>
<div id="content-main" class="constance"> <div id="content-main" class="constance">
<div class="module" id="changelist"> <div class="module" id="changelist">
<form id="changelist-form" action="" method="post" enctype="multipart/form-data">{% csrf_token %} <form id="changelist-form" action="" method="post" enctype="multipart/form-data">{% csrf_token %}

View file

@ -0,0 +1,55 @@
{% extends "admin/base_site.html" %}
{% load i18n %}
{% block breadcrumbs %}
<div class="breadcrumbs">
<a href="{% url 'admin:index' %}">{% translate 'Home' %}</a>
&rsaquo; <a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ opts.app_config.verbose_name }}</a>
&rsaquo; <a href="{% url 'admin:constance_config_changelist' %}">{{ opts.verbose_name_plural|capfirst }}</a>
&rsaquo; {% translate 'History' %}
</div>
{% endblock %}
{% block content %}
<div id="content-main">
<div id="change-history" class="module">
{% if action_list %}
<table>
<thead>
<tr>
<th scope="col">{% translate 'Date/time' %}</th>
<th scope="col">{% translate 'User' %}</th>
<th scope="col">{% translate 'Action' %}</th>
</tr>
</thead>
<tbody>
{% for action in action_list %}
<tr>
<th scope="row">{{ action.action_time|date:"DATETIME_FORMAT" }}</th>
<td>{{ action.user.get_username }}{% if action.user.get_full_name %} ({{ action.user.get_full_name }}){% endif %}</td>
<td>{{ action.get_change_message }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<p class="paginator">
{% if pagination_required %}
{% for i in page_range %}
{% if i == action_list.paginator.ELLIPSIS %}
{{ action_list.paginator.ELLIPSIS }}
{% elif i == action_list.number %}
<span class="this-page">{{ i }}</span>
{% else %}
<a href="?{{ page_var }}={{ i }}" {% if i == action_list.paginator.num_pages %} class="end" {% endif %}>{{ i }}</a>
{% endif %}
{% endfor %}
{% endif %}
{{ action_list.paginator.count }} {% blocktranslate count counter=action_list.paginator.count %}entry{% plural %}entries{% endblocktranslate %}
</p>
{% else %}
<p>{% translate "This object doesn't have a change history." %}</p>
{% endif %}
</div>
</div>
{% endblock %}

View file

@ -1,14 +1,20 @@
import json
from datetime import datetime from datetime import datetime
from unittest import mock from unittest import mock
from django.contrib import admin from django.contrib import admin
from django.contrib.admin.models import CHANGE
from django.contrib.admin.models import LogEntry
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.http import HttpResponseRedirect from django.http import HttpResponseRedirect
from django.template.defaultfilters import linebreaksbr from django.template.defaultfilters import linebreaksbr
from django.test import RequestFactory from django.test import RequestFactory
from django.test import TestCase from django.test import TestCase
from django.urls import resolve
from django.urls import reverse
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from constance import settings from constance import settings
@ -28,6 +34,9 @@ class TestAdmin(TestCase):
self.normaluser.is_staff = True self.normaluser.is_staff = True
self.normaluser.save() self.normaluser.save()
self.options = admin.site._registry[self.model] self.options = admin.site._registry[self.model]
# Clear ContentType cache to avoid stale content_type_id references
# across tests wrapped in transactions.
ContentType.objects.clear_cache()
def test_changelist(self): def test_changelist(self):
self.client.login(username="admin", password="nimda") self.client.login(username="admin", password="nimda")
@ -126,7 +135,7 @@ class TestAdmin(TestCase):
}, },
) )
@mock.patch("constance.settings.IGNORE_ADMIN_VERSION_CHECK", True) @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) @mock.patch("constance.forms.ConstanceForm.is_valid", lambda _: True)
def test_submit(self): def test_submit(self):
""" """
@ -322,6 +331,137 @@ class TestAdmin(TestCase):
# Clean up FIELDS to avoid leaking into other tests # Clean up FIELDS to avoid leaking into other tests
FIELDS.pop("language_select", None) 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."""
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."""
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_history_view(self):
"""Test that the history view renders and shows LogEntry records."""
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("doesn't have a change history", content)
def test_history_view_permission_denied(self):
"""Test that the history view denies access to users without permission."""
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()
history_url = reverse("admin:constance_config_history")
self.assertIn(f'href="{history_url}"', 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."""
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.
match = resolve(url)
response = match.func(request, object_id="Config")
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, "../../")
def test_labels(self): def test_labels(self):
self.assertEqual(type(self.model._meta.label), str) self.assertEqual(type(self.model._meta.label), str)
self.assertEqual(type(self.model._meta.label_lower), str) self.assertEqual(type(self.model._meta.label_lower), str)