Fixed #21113 -- Made LogEntry.change_message language independent

Thanks Tim Graham for the review.
This commit is contained in:
Claude Paroz 2015-12-26 19:51:22 +01:00
parent 56aaae58a7
commit cf7894be88
11 changed files with 216 additions and 35 deletions

View File

@ -1,5 +1,7 @@
from __future__ import unicode_literals from __future__ import unicode_literals
import json
from django.conf import settings from django.conf import settings
from django.contrib.admin.utils import quote from django.contrib.admin.utils import quote
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
@ -7,6 +9,7 @@ from django.db import models
from django.urls import NoReverseMatch, reverse from django.urls import NoReverseMatch, reverse
from django.utils import timezone from django.utils import timezone
from django.utils.encoding import python_2_unicode_compatible, smart_text from django.utils.encoding import python_2_unicode_compatible, smart_text
from django.utils.text import get_text_list
from django.utils.translation import ugettext, ugettext_lazy as _ from django.utils.translation import ugettext, ugettext_lazy as _
ADDITION = 1 ADDITION = 1
@ -18,6 +21,8 @@ class LogEntryManager(models.Manager):
use_in_migrations = True use_in_migrations = True
def log_action(self, user_id, content_type_id, object_id, object_repr, action_flag, change_message=''): def log_action(self, user_id, content_type_id, object_id, object_repr, action_flag, change_message=''):
if isinstance(change_message, list):
change_message = json.dumps(change_message)
self.model.objects.create( self.model.objects.create(
user_id=user_id, user_id=user_id,
content_type_id=content_type_id, content_type_id=content_type_id,
@ -50,6 +55,7 @@ class LogEntry(models.Model):
# Translators: 'repr' means representation (https://docs.python.org/3/library/functions.html#repr) # Translators: 'repr' means representation (https://docs.python.org/3/library/functions.html#repr)
object_repr = models.CharField(_('object repr'), max_length=200) object_repr = models.CharField(_('object repr'), max_length=200)
action_flag = models.PositiveSmallIntegerField(_('action flag')) action_flag = models.PositiveSmallIntegerField(_('action flag'))
# change_message is either a string or a JSON structure
change_message = models.TextField(_('change message'), blank=True) change_message = models.TextField(_('change message'), blank=True)
objects = LogEntryManager() objects = LogEntryManager()
@ -69,7 +75,7 @@ class LogEntry(models.Model):
elif self.is_change(): elif self.is_change():
return ugettext('Changed "%(object)s" - %(changes)s') % { return ugettext('Changed "%(object)s" - %(changes)s') % {
'object': self.object_repr, 'object': self.object_repr,
'changes': self.change_message, 'changes': self.get_change_message(),
} }
elif self.is_deletion(): elif self.is_deletion():
return ugettext('Deleted "%(object)s."') % {'object': self.object_repr} return ugettext('Deleted "%(object)s."') % {'object': self.object_repr}
@ -85,6 +91,46 @@ class LogEntry(models.Model):
def is_deletion(self): def is_deletion(self):
return self.action_flag == DELETION return self.action_flag == DELETION
def get_change_message(self):
"""
If self.change_message is a JSON structure, interpret it as a change
string, properly translated.
"""
if self.change_message and self.change_message[0] == '[':
try:
change_message = json.loads(self.change_message)
except ValueError:
return self.change_message
messages = []
for sub_message in change_message:
if 'added' in sub_message:
if sub_message['added']:
sub_message['added']['name'] = ugettext(sub_message['added']['name'])
messages.append(ugettext('Added {name} "{object}".').format(**sub_message['added']))
else:
messages.append(ugettext('Added.'))
elif 'changed' in sub_message:
sub_message['changed']['fields'] = get_text_list(
sub_message['changed']['fields'], ugettext('and')
)
if 'name' in sub_message['changed']:
sub_message['changed']['name'] = ugettext(sub_message['changed']['name'])
messages.append(ugettext('Changed {fields} for {name} "{object}".').format(
**sub_message['changed']
))
else:
messages.append(ugettext('Changed {fields}.').format(**sub_message['changed']))
elif 'deleted' in sub_message:
sub_message['deleted']['name'] = ugettext(sub_message['deleted']['name'])
messages.append(ugettext('Deleted {name} "{object}".').format(**sub_message['deleted']))
change_message = ' '.join(msg[0].upper() + msg[1:] for msg in messages)
return change_message or ugettext('No fields changed.')
else:
return self.change_message
def get_edited_object(self): def get_edited_object(self):
"Returns the edited object represented by this log entry" "Returns the edited object represented by this log entry"
return self.content_type.get_object_for_this_type(pk=self.object_id) return self.content_type.get_object_for_this_type(pk=self.object_id)

View File

@ -44,7 +44,9 @@ from django.utils.html import escape, format_html
from django.utils.http import urlencode, urlquote from django.utils.http import urlencode, urlquote
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.utils.text import capfirst, get_text_list from django.utils.text import capfirst, get_text_list
from django.utils.translation import string_concat, ugettext as _, ungettext from django.utils.translation import (
override as translation_override, string_concat, ugettext as _, ungettext,
)
from django.views.decorators.csrf import csrf_protect from django.views.decorators.csrf import csrf_protect
from django.views.generic import RedirectView from django.views.generic import RedirectView
@ -924,33 +926,44 @@ class ModelAdmin(BaseModelAdmin):
return urlencode({'_changelist_filters': preserved_filters}) return urlencode({'_changelist_filters': preserved_filters})
return '' return ''
@translation_override(None)
def construct_change_message(self, request, form, formsets, add=False): def construct_change_message(self, request, form, formsets, add=False):
""" """
Construct a change message from a changed object. Construct a JSON structure describing changes from a changed object.
Translations are deactivated so that strings are stored untranslated.
Translation happens later on LogEntry access.
""" """
change_message = [] change_message = []
if add: if add:
change_message.append(_('Added.')) change_message.append({'added': {}})
elif form.changed_data: elif form.changed_data:
change_message.append(_('Changed %s.') % get_text_list(form.changed_data, _('and'))) change_message.append({'changed': {'fields': form.changed_data}})
if formsets: if formsets:
for formset in formsets: for formset in formsets:
for added_object in formset.new_objects: for added_object in formset.new_objects:
change_message.append(_('Added %(name)s "%(object)s".') change_message.append({
% {'name': force_text(added_object._meta.verbose_name), 'added': {
'object': force_text(added_object)}) 'name': force_text(added_object._meta.verbose_name),
'object': force_text(added_object),
}
})
for changed_object, changed_fields in formset.changed_objects: for changed_object, changed_fields in formset.changed_objects:
change_message.append(_('Changed %(list)s for %(name)s "%(object)s".') change_message.append({
% {'list': get_text_list(changed_fields, _('and')), 'changed': {
'name': force_text(changed_object._meta.verbose_name), 'name': force_text(changed_object._meta.verbose_name),
'object': force_text(changed_object)}) 'object': force_text(changed_object),
'fields': changed_fields,
}
})
for deleted_object in formset.deleted_objects: for deleted_object in formset.deleted_objects:
change_message.append(_('Deleted %(name)s "%(object)s".') change_message.append({
% {'name': force_text(deleted_object._meta.verbose_name), 'deleted': {
'object': force_text(deleted_object)}) 'name': force_text(deleted_object._meta.verbose_name),
change_message = ' '.join(change_message) 'object': force_text(deleted_object),
return change_message or _('No fields changed.') }
})
return change_message
def message_user(self, request, message, level=messages.INFO, extra_tags='', def message_user(self, request, message, level=messages.INFO, extra_tags='',
fail_silently=False): fail_silently=False):

View File

@ -29,7 +29,7 @@
<tr> <tr>
<th scope="row">{{ action.action_time|date:"DATETIME_FORMAT" }}</th> <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.user.get_username }}{% if action.user.get_full_name %} ({{ action.user.get_full_name }}){% endif %}</td>
<td>{{ action.change_message }}</td> <td>{{ action.get_change_message }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>

View File

@ -2823,7 +2823,18 @@ password box.
.. attribute:: LogEntry.change_message .. attribute:: LogEntry.change_message
The detailed description of the modification. In the case of an edit, for The detailed description of the modification. In the case of an edit, for
example, the message contains a list of the edited fields. example, the message contains a list of the edited fields. The Django admin
site formats this content as a JSON structure, so that
:meth:`get_change_message` can recompose a message translated in the current
user language. Custom code might set this as a plain string though. You are
advised to use the :meth:`get_change_message` method to retrieve this value
instead of accessing it directly.
.. versionchanged:: 1.10
Previously, this attribute was always a plain string. It is
now JSON-structured so that the message can be translated in the current
user language. Old messages are untouched.
``LogEntry`` methods ``LogEntry`` methods
-------------------- --------------------
@ -2832,6 +2843,14 @@ password box.
A shortcut that returns the referenced object. A shortcut that returns the referenced object.
.. method:: LogEntry.get_change_message()
.. versionadded:: 1.10
Formats and translates :attr:`change_message` into the current user
language. Messages created before Django 1.10 will always be displayed in
the language in which they were logged.
.. currentmodule:: django.contrib.admin .. currentmodule:: django.contrib.admin
.. _admin-reverse-urls: .. _admin-reverse-urls:

View File

@ -51,6 +51,11 @@ Minor features
model's changelist will now be rendered (without the add button, of course). model's changelist will now be rendered (without the add button, of course).
This makes it easier to add custom tools in this case. This makes it easier to add custom tools in this case.
* The :class:`~django.contrib.admin.models.LogEntry` model now stores change
messages in a JSON structure so that the message can be dynamically translated
using the current active language. A new ``LogEntry.get_change_message()``
method is now the preferred way of retrieving the change message.
:mod:`django.contrib.admindocs` :mod:`django.contrib.admindocs`
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
@ -417,6 +422,10 @@ Miscellaneous
* :djadmin:`loaddata` now raises a ``CommandError`` instead of showing a * :djadmin:`loaddata` now raises a ``CommandError`` instead of showing a
warning when the specified fixture file is not found. warning when the specified fixture file is not found.
* Instead of directly accessing the ``LogEntry.change_message`` attribute, it's
now better to call the ``LogEntry.get_change_message()`` method which will
provide the message in the current language.
.. _deprecated-features-1.10: .. _deprecated-features-1.10:
Features deprecated in 1.10 Features deprecated in 1.10

View File

@ -1,7 +1,18 @@
from django.contrib import admin from django.contrib import admin
from .models import Article, ArticleProxy from .models import Article, ArticleProxy, Site
class ArticleInline(admin.TabularInline):
model = Article
fields = ['title']
class SiteAdmin(admin.ModelAdmin):
inlines = [ArticleInline]
site = admin.AdminSite(name='admin') site = admin.AdminSite(name='admin')
site.register(Article) site.register(Article)
site.register(ArticleProxy) site.register(ArticleProxy)
site.register(Site, SiteAdmin)

View File

@ -1,6 +1,7 @@
from django.db import models from django.db import models
from django.utils import six from django.utils import six
from django.utils.encoding import python_2_unicode_compatible from django.utils.encoding import python_2_unicode_compatible
from django.utils.translation import ugettext_lazy as _
@python_2_unicode_compatible @python_2_unicode_compatible
@ -17,8 +18,8 @@ class Article(models.Model):
""" """
site = models.ForeignKey(Site, models.CASCADE, related_name="admin_articles") site = models.ForeignKey(Site, models.CASCADE, related_name="admin_articles")
title = models.CharField(max_length=100) title = models.CharField(max_length=100)
title2 = models.CharField(max_length=100, verbose_name="another name") hist = models.CharField(max_length=100, verbose_name=_("History"))
created = models.DateTimeField() created = models.DateTimeField(null=True)
def test_from_model(self): def test_from_model(self):
return "nothing" return "nothing"

View File

@ -1,5 +1,7 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals from __future__ import unicode_literals
import json
from datetime import datetime from datetime import datetime
from django.contrib.admin.models import ADDITION, CHANGE, DELETION, LogEntry from django.contrib.admin.models import ADDITION, CHANGE, DELETION, LogEntry
@ -8,7 +10,7 @@ from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.test import TestCase, override_settings from django.test import TestCase, override_settings
from django.urls import reverse from django.urls import reverse
from django.utils import six from django.utils import six, translation
from django.utils.encoding import force_bytes from django.utils.encoding import force_bytes
from django.utils.html import escape from django.utils.html import escape
@ -28,7 +30,7 @@ class LogEntryTests(TestCase):
self.a1 = Article.objects.create( self.a1 = Article.objects.create(
site=self.site, site=self.site,
title="Title", title="Title",
created=datetime(2008, 3, 18, 11, 54, 58), created=datetime(2008, 3, 18, 11, 54),
) )
content_type_pk = ContentType.objects.get_for_model(Article).pk content_type_pk = ContentType.objects.get_for_model(Article).pk
LogEntry.objects.log_action( LogEntry.objects.log_action(
@ -47,6 +49,86 @@ class LogEntryTests(TestCase):
logentry.save() logentry.save()
self.assertEqual(logentry.action_time, action_time) self.assertEqual(logentry.action_time, action_time)
def test_logentry_change_message(self):
"""
LogEntry.change_message is stored as a dumped JSON structure to be able
to get the message dynamically translated at display time.
"""
post_data = {
'site': self.site.pk, 'title': 'Changed', 'hist': 'Some content',
'created_0': '2008-03-18', 'created_1': '11:54',
}
change_url = reverse('admin:admin_utils_article_change', args=[quote(self.a1.pk)])
response = self.client.post(change_url, post_data)
self.assertRedirects(response, reverse('admin:admin_utils_article_changelist'))
logentry = LogEntry.objects.filter(content_type__model__iexact='article').latest('action_time')
self.assertEqual(logentry.get_change_message(), 'Changed title and hist.')
with translation.override('fr'):
self.assertEqual(logentry.get_change_message(), 'Modification de title et hist.')
add_url = reverse('admin:admin_utils_article_add')
post_data['title'] = 'New'
response = self.client.post(add_url, post_data)
self.assertRedirects(response, reverse('admin:admin_utils_article_changelist'))
logentry = LogEntry.objects.filter(content_type__model__iexact='article').latest('action_time')
self.assertEqual(logentry.get_change_message(), 'Added.')
with translation.override('fr'):
self.assertEqual(logentry.get_change_message(), 'Ajout.')
def test_logentry_change_message_formsets(self):
"""
All messages for changed formsets are logged in a change message.
"""
a2 = Article.objects.create(
site=self.site,
title="Title second article",
created=datetime(2012, 3, 18, 11, 54),
)
post_data = {
'domain': 'example.com', # domain changed
'admin_articles-TOTAL_FORMS': '5',
'admin_articles-INITIAL_FORMS': '2',
'admin_articles-MIN_NUM_FORMS': '0',
'admin_articles-MAX_NUM_FORMS': '1000',
# Changed title for 1st article
'admin_articles-0-id': str(self.a1.pk),
'admin_articles-0-site': str(self.site.pk),
'admin_articles-0-title': 'Changed Title',
# Second article is deleted
'admin_articles-1-id': str(a2.pk),
'admin_articles-1-site': str(self.site.pk),
'admin_articles-1-title': 'Title second article',
'admin_articles-1-DELETE': 'on',
# A new article is added
'admin_articles-2-site': str(self.site.pk),
'admin_articles-2-title': 'Added article',
}
change_url = reverse('admin:admin_utils_site_change', args=[quote(self.site.pk)])
response = self.client.post(change_url, post_data)
self.assertRedirects(response, reverse('admin:admin_utils_site_changelist'))
self.assertQuerysetEqual(Article.objects.filter(pk=a2.pk), [])
logentry = LogEntry.objects.filter(content_type__model__iexact='site').latest('action_time')
self.assertEqual(
json.loads(logentry.change_message),
[
{"changed": {"fields": ["domain"]}},
{"added": {"object": "Article object", "name": "article"}},
{"changed": {"fields": ["title"], "object": "Article object", "name": "article"}},
{"deleted": {"object": "Article object", "name": "article"}},
]
)
self.assertEqual(
logentry.get_change_message(),
'Changed domain. Added article "Article object". '
'Changed title for article "Article object". Deleted article "Article object".'
)
with translation.override('fr'):
self.assertEqual(
logentry.get_change_message(),
'Modification de domain. Article « Article object » ajouté. '
'Modification de title pour l\'objet article « Article object ». Article « Article object » supprimé.'
)
def test_logentry_get_edited_object(self): def test_logentry_get_edited_object(self):
""" """
LogEntry.get_edited_object() returns the edited object of a LogEntry LogEntry.get_edited_object() returns the edited object of a LogEntry
@ -114,7 +196,7 @@ class LogEntryTests(TestCase):
""" """
proxy_content_type = ContentType.objects.get_for_model(ArticleProxy, for_concrete_model=False) proxy_content_type = ContentType.objects.get_for_model(ArticleProxy, for_concrete_model=False)
post_data = { post_data = {
'site': self.site.pk, 'title': "Foo", 'title2': "Bar", 'site': self.site.pk, 'title': "Foo", 'hist': "Bar",
'created_0': '2015-12-25', 'created_1': '00:00', 'created_0': '2015-12-25', 'created_1': '00:00',
} }
changelist_url = reverse('admin:admin_utils_articleproxy_changelist') changelist_url = reverse('admin:admin_utils_articleproxy_changelist')

View File

@ -203,12 +203,12 @@ class UtilsTests(SimpleTestCase):
"title" "title"
) )
self.assertEqual( self.assertEqual(
label_for_field("title2", Article), label_for_field("hist", Article),
"another name" "History"
) )
self.assertEqual( self.assertEqual(
label_for_field("title2", Article, return_attr=True), label_for_field("hist", Article, return_attr=True),
("another name", None) ("History", None)
) )
self.assertEqual( self.assertEqual(

View File

@ -1542,7 +1542,7 @@ class AdminViewPermissionsTest(TestCase):
self.assertEqual(addition_log.object_id, str(new_article.pk)) self.assertEqual(addition_log.object_id, str(new_article.pk))
self.assertEqual(addition_log.object_repr, "Døm ikke") self.assertEqual(addition_log.object_repr, "Døm ikke")
self.assertEqual(addition_log.action_flag, ADDITION) self.assertEqual(addition_log.action_flag, ADDITION)
self.assertEqual(addition_log.change_message, "Added.") self.assertEqual(addition_log.get_change_message(), "Added.")
# Super can add too, but is redirected to the change list view # Super can add too, but is redirected to the change list view
self.client.force_login(self.superuser) self.client.force_login(self.superuser)

View File

@ -931,7 +931,7 @@ class ChangelistTests(AuthViewsTestCase):
) )
self.assertRedirects(response, reverse('auth_test_admin:auth_user_changelist')) self.assertRedirects(response, reverse('auth_test_admin:auth_user_changelist'))
row = LogEntry.objects.latest('id') row = LogEntry.objects.latest('id')
self.assertEqual(row.change_message, 'Changed email.') self.assertEqual(row.get_change_message(), 'Changed email.')
def test_user_not_change(self): def test_user_not_change(self):
response = self.client.post( response = self.client.post(
@ -940,7 +940,7 @@ class ChangelistTests(AuthViewsTestCase):
) )
self.assertRedirects(response, reverse('auth_test_admin:auth_user_changelist')) self.assertRedirects(response, reverse('auth_test_admin:auth_user_changelist'))
row = LogEntry.objects.latest('id') row = LogEntry.objects.latest('id')
self.assertEqual(row.change_message, 'No fields changed.') self.assertEqual(row.get_change_message(), 'No fields changed.')
def test_user_change_password(self): def test_user_change_password(self):
user_change_url = reverse('auth_test_admin:auth_user_change', args=(self.admin.pk,)) user_change_url = reverse('auth_test_admin:auth_user_change', args=(self.admin.pk,))
@ -966,7 +966,7 @@ class ChangelistTests(AuthViewsTestCase):
) )
self.assertRedirects(response, user_change_url) self.assertRedirects(response, user_change_url)
row = LogEntry.objects.latest('id') row = LogEntry.objects.latest('id')
self.assertEqual(row.change_message, 'Changed password.') self.assertEqual(row.get_change_message(), 'Changed password.')
self.logout() self.logout()
self.login(password='password1') self.login(password='password1')
@ -983,7 +983,7 @@ class ChangelistTests(AuthViewsTestCase):
row = LogEntry.objects.latest('id') row = LogEntry.objects.latest('id')
self.assertEqual(row.user_id, self.admin.pk) self.assertEqual(row.user_id, self.admin.pk)
self.assertEqual(row.object_id, str(u.pk)) self.assertEqual(row.object_id, str(u.pk))
self.assertEqual(row.change_message, 'Changed password.') self.assertEqual(row.get_change_message(), 'Changed password.')
def test_password_change_bad_url(self): def test_password_change_bad_url(self):
response = self.client.get(reverse('auth_test_admin:auth_user_password_change', args=('foobar',))) response = self.client.get(reverse('auth_test_admin:auth_user_password_change', args=('foobar',)))
@ -1018,4 +1018,4 @@ class UUIDUserTests(TestCase):
row = LogEntry.objects.latest('id') row = LogEntry.objects.latest('id')
self.assertEqual(row.user_id, 1) # hardcoded in CustomUserAdmin.log_change() self.assertEqual(row.user_id, 1) # hardcoded in CustomUserAdmin.log_change()
self.assertEqual(row.object_id, str(u.pk)) self.assertEqual(row.object_id, str(u.pk))
self.assertEqual(row.change_message, 'Changed password.') self.assertEqual(row.get_change_message(), 'Changed password.')