Kaydet (Commit) cf7894be authored tarafından Claude Paroz's avatar Claude Paroz

Fixed #21113 -- Made LogEntry.change_message language independent

Thanks Tim Graham for the review.
üst 56aaae58
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 ...@@ -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): ...@@ -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): ...@@ -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): ...@@ -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): ...@@ -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)
......
...@@ -44,7 +44,9 @@ from django.utils.html import escape, format_html ...@@ -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): ...@@ -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):
......
...@@ -29,7 +29,7 @@ ...@@ -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>
......
...@@ -2823,7 +2823,18 @@ password box. ...@@ -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. ...@@ -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:
......
...@@ -51,6 +51,11 @@ Minor features ...@@ -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 ...@@ -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
......
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)
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): ...@@ -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"
......
# -*- 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 ...@@ -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): ...@@ -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): ...@@ -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): ...@@ -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')
......
...@@ -203,12 +203,12 @@ class UtilsTests(SimpleTestCase): ...@@ -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(
......
...@@ -1542,7 +1542,7 @@ class AdminViewPermissionsTest(TestCase): ...@@ -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)
......
...@@ -931,7 +931,7 @@ class ChangelistTests(AuthViewsTestCase): ...@@ -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): ...@@ -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): ...@@ -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): ...@@ -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): ...@@ -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.')
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment