Merge pull request #299 from dbrgn/iss287

Implemented custom value renderers
This commit is contained in:
Daniel Greenfeld 2013-07-07 06:21:14 -07:00
commit 89e6cd8db0
15 changed files with 414 additions and 69 deletions

83
djadmin2/renderers.py Normal file
View file

@ -0,0 +1,83 @@
# -*- coding: utf-8 -*-
"""
There are currently a few renderers that come directly with django-admin2. They
are used by default for some field types.
"""
from __future__ import division, absolute_import, unicode_literals
import os.path
from decimal import Decimal
from datetime import date, time, datetime
from django.db import models
from django.utils import formats, timezone
from django.template.loader import render_to_string
from djadmin2 import settings
def boolean_renderer(value, field):
"""
Render a boolean value as icon.
This uses the template ``renderers/boolean.html``.
:param value: The value to process.
:type value: boolean
:param field: The model field instance
:type field: django.db.models.fields.Field
:rtype: unicode
"""
# TODO caching of template
tpl = os.path.join(settings.ADMIN2_THEME_DIRECTORY, 'renderers/boolean.html')
return render_to_string(tpl, {'value': value})
def datetime_renderer(value, field):
"""
Localize and format the specified date.
:param value: The value to process.
:type value: datetime.date or datetime.time or datetime.datetime
:param field: The model field instance
:type field: django.db.models.fields.Field
:rtype: unicode
"""
if isinstance(value, datetime):
return formats.localize(timezone.template_localtime(value))
elif isinstance(value, (date, time)):
return formats.localize(value)
else:
return value
def title_renderer(value, field):
"""
Render a string in title case (capitalize every word).
:param value: The value to process.
:type value: str or unicode
:param field: The model field instance
:type field: django.db.models.fields.Field
:rtype: unicode
"""
return unicode(value).title()
def number_renderer(value, field):
"""
Format a number.
:param value: The value to process.
:type value: float or long
:param field: The model field instance
:type field: django.db.models.fields.Field
:rtype: unicode
"""
if isinstance(field, models.DecimalField):
return formats.number_format(value, field.decimal_places)
return formats.number_format(value)

View file

@ -6,6 +6,7 @@ from django.conf import settings
MODEL_ADMIN_ATTRS = (
'list_display', 'list_display_links', 'list_filter', 'admin',
'search_fields',
'field_renderers',
'index_view', 'detail_view', 'create_view', 'update_view', 'delete_view',
'get_default_view_kwargs', 'get_list_actions',
'actions_on_bottom', 'actions_on_top',

View file

@ -84,12 +84,12 @@
{% for attr in view.model_admin.list_display %}
<td>
{% if permissions.has_change_permission %}
<a href="{% url view|admin2_urlname:'update' pk=obj.pk %}">{% get_attr obj attr %}</a>
<a href="{% url view|admin2_urlname:'update' pk=obj.pk %}">{% render obj attr %}</a>
{% else %}
{% if permissions.has_view_permission %}
<a href="{% url view|admin2_urlname:'detail' pk=obj.pk %}">{% get_attr obj attr %}</a>
<a href="{% url view|admin2_urlname:'detail' pk=obj.pk %}">{% render obj attr %}</a>
{% else %}
{% get_attr obj attr %}
{% render obj attr %}
{% endif %}
{% endif %}
</td>

View file

@ -0,0 +1,5 @@
{% if value %}
<i class="icon-ok-sign" title="True"></i>
{% else %}
<i class="icon-minus-sign" title="False"></i>
{% endif %}

View file

@ -1,9 +1,11 @@
from numbers import Number
from datetime import date, time, datetime
from django import template
from django.db.models.fields import FieldDoesNotExist
register = template.Library()
from .. import utils
from .. import utils, renderers
@register.filter
@ -97,12 +99,35 @@ def for_object(permissions, obj):
return permissions.bind_object(obj)
@register.simple_tag
def get_attr(record, attribute_name):
""" Allows dynamic fetching of model attributes in templates """
if attribute_name == "__str__":
return record.__unicode__()
attribute = getattr(record, attribute_name)
if callable(attribute):
return attribute()
return attribute
@register.simple_tag(takes_context=True)
def render(context, model_instance, attribute_name):
"""
This filter applies all renderers specified in admin2.py to the field.
"""
value = utils.get_attr(model_instance, attribute_name)
# Get renderer
admin = context['view'].model_admin
renderer = admin.field_renderers.get(attribute_name, False)
if renderer is None:
# Renderer has explicitly been overridden
return value
if not renderer:
# Try to automatically pick best renderer
if isinstance(value, bool):
renderer = renderers.boolean_renderer
elif isinstance(value, (date, time, datetime)):
renderer = renderers.datetime_renderer
elif isinstance(value, Number):
renderer = renderers.number_renderer
else:
return value
# Apply renderer and return value
try:
field = model_instance._meta.get_field_by_name(attribute_name)[0]
except FieldDoesNotExist:
# There is no field with the specified name.
# It must be a method instead.
field = None
return renderer(value, field)

View file

@ -5,3 +5,4 @@ from test_views import *
from test_core import *
from test_actions import *
from test_auth_admin import *
from test_renderers import *

View file

@ -90,35 +90,3 @@ class TagsTests(TestCase):
admin2_tags.formset_visible_fieldlist(formset),
[u'Visible 1', u'Visible 2']
)
def test_get_attr_callable(self):
class Klass(object):
def hello(self):
return "hello"
self.assertEquals(
admin2_tags.get_attr(Klass(), "hello"),
"hello"
)
def test_get_attr_str(self):
class Klass(object):
def __str__(self):
return "str"
def __unicode__(self):
return "unicode"
self.assertEquals(
admin2_tags.get_attr(Klass(), "__str__"),
"unicode"
)
def test_get_attr(self):
class Klass(object):
attr = "value"
self.assertEquals(
admin2_tags.get_attr(Klass(), "attr"),
"value"
)

View file

@ -0,0 +1,118 @@
# -*- coding: utf-8 -*-
from __future__ import division, absolute_import, unicode_literals
import datetime as dt
from decimal import Decimal
from django.test import TestCase
from django.db import models
from django.utils.translation import activate
from .. import renderers
class RendererTestModel(models.Model):
decimal = models.DecimalField(decimal_places=5)
class BooleanRendererTest(TestCase):
def setUp(self):
self.renderer = renderers.boolean_renderer
def test_boolean(self):
out1 = self.renderer(True, None)
self.assertIn('icon-ok-sign', out1)
out2 = self.renderer(False, None)
self.assertIn('icon-minus-sign', out2)
def test_string(self):
out1 = self.renderer('yeah', None)
self.assertIn('icon-ok-sign', out1)
out2 = self.renderer('', None)
self.assertIn('icon-minus-sign', out2)
class DatetimeRendererTest(TestCase):
def setUp(self):
self.renderer = renderers.datetime_renderer
def tearDown(self):
activate('en_US')
def test_date_german(self):
activate('de')
out = self.renderer(dt.date(2013, 7, 6), None)
self.assertEqual('6. Juli 2013', out)
def test_date_spanish(self):
activate('es')
out = self.renderer(dt.date(2013, 7, 6), None)
self.assertEqual('6 de Julio de 2013', out)
def test_date_default(self):
out = self.renderer(dt.date(2013, 7, 6), None)
self.assertEqual('July 6, 2013', out)
def test_time_german(self):
activate('de')
out = self.renderer(dt.time(13, 37, 01), None)
self.assertEqual('13:37:01', out)
def test_time_chinese(self):
activate('zh')
out = self.renderer(dt.time(13, 37, 01), None)
self.assertEqual('1:37 p.m.', out)
def test_datetime(self):
out = self.renderer(dt.datetime(2013, 7, 6, 13, 37, 01), None)
self.assertEqual('July 6, 2013, 1:37 p.m.', out)
# TODO test timezone localization
class TitleRendererTest(TestCase):
def setUp(self):
self.renderer = renderers.title_renderer
def testLowercase(self):
out = self.renderer('oh hello there!', None)
self.assertEqual('Oh Hello There!', out)
def testTitlecase(self):
out = self.renderer('Oh Hello There!', None)
self.assertEqual('Oh Hello There!', out)
def testUppercase(self):
out = self.renderer('OH HELLO THERE!', None)
self.assertEqual('Oh Hello There!', out)
class NumberRendererTest(TestCase):
def setUp(self):
self.renderer = renderers.number_renderer
def testInteger(self):
out = self.renderer(42, None)
self.assertEqual('42', out)
def testFloat(self):
out = self.renderer(42.5, None)
self.assertEqual('42.5', out)
def testEndlessFloat(self):
out = self.renderer(1.0/3, None)
self.assertEqual('0.333333333333', out)
def testPlainDecimal(self):
number = '0.123456789123456789123456789'
out = self.renderer(Decimal(number), None)
self.assertEqual(number, out)
def testFieldDecimal(self):
field = RendererTestModel._meta.get_field_by_name('decimal')[0]
out = self.renderer(Decimal('0.123456789'), field)
self.assertEqual('0.12345', out)

View file

@ -135,3 +135,35 @@ class UtilsTest(TestCase):
self.instance._meta.app_label,
utils.model_app_label(self.instance)
)
def test_get_attr_callable(self):
class Klass(object):
def hello(self):
return "hello"
self.assertEquals(
utils.get_attr(Klass(), "hello"),
"hello"
)
def test_get_attr_str(self):
class Klass(object):
def __str__(self):
return "str"
def __unicode__(self):
return "unicode"
self.assertEquals(
utils.get_attr(Klass(), "__str__"),
"unicode"
)
def test_get_attr(self):
class Klass(object):
attr = "value"
self.assertEquals(
utils.get_attr(Klass(), "attr"),
"value"
)

View file

@ -63,15 +63,18 @@ class ModelAdmin2(object):
# TODO: Confirm that this is what the Django admin uses
list_fields = []
#This shows up on the DocumentListView of the Posts
# This shows up on the DocumentListView of the Posts
list_actions = [actions.DeleteSelectedAction]
# This shows up in the DocumentDetailView of the Posts.
document_actions = []
# shows up on a particular field
# Shows up on a particular field
field_actions = {}
# Defines custom field renderers
field_renderers = {}
fields = None
exclude = None
fieldsets = None

View file

@ -83,6 +83,19 @@ def model_app_label(obj):
return model_options(obj).app_label
def get_attr(obj, attr):
"""
Get the right value for the attribute. Handle special cases like callables
and the __str__ attribute.
"""
if attr == '__str__':
value = unicode(obj)
else:
attribute = getattr(obj, attr)
value = attribute() if callable(attribute) else attribute
return value
class NestedObjects(Collector):
"""
This is adopted from the Django core. django-admin2 mandates that code

View file

@ -80,6 +80,7 @@ Reference
ref/permissions
ref/views
ref/built-in-views
ref/renderers
ref/meta
Indices and tables

62
docs/ref/renderers.rst Normal file
View file

@ -0,0 +1,62 @@
================
Custom Renderers
================
It is possible to create custom renderers for specific fields. Currently they
are only used in the object list view, for example to render boolean values
using icons. Another example would be to customize the rendering of dates.
Renderers
---------
A renderer is a function that accepts a value and the field and returns a HTML
representation of it. For example, the very simple builtin datetime renderer
works like this:
.. code-block:: python
def title_renderer(value, field):
"""Render a string in title case (capitalize every word)."""
return unicode(value).title()
In this case the ``field`` argument is not used. Sometimes it useful though:
.. code-block:: python
def number_renderer(value, field):
"""Format a number."""
if isinstance(field, models.DecimalField):
return formats.number_format(value, field.decimal_places)
return formats.number_format(value)
You can create your renderers anywhere in your code, but it is recommended to
put them in a file called ``renderers.py`` in your project.
Using Renderers
---------------
The renderers can be specified in the Admin2 class using the
``field_renderers`` attribute. The attribute contains a dictionary that maps a
field name to a renderer function.
By default, some renderers are automatically applied, for example the boolean
renderer when processing boolean values. If you want to suppress that renderer,
you can assign ``None`` to the field in the ``field_renderers`` dictionary.
.. code-block:: python
class PostAdmin(djadmin2.ModelAdmin2):
list_display = ('title', 'body', 'published')
field_renderers = {
'title': renderers.title_renderer,
'published': None,
}
Builtin Renderers
-----------------
.. automodule:: djadmin2.renderers
:members:

View file

@ -1,12 +1,16 @@
# -*- coding: utf-8 -*-
# Import your custom models
from django.contrib.auth.models import Group, User
from django.contrib import messages
from django.utils.translation import ugettext_lazy
import djadmin2
from djadmin2 import renderers
from djadmin2.actions import DeleteSelectedAction
# Import your custom models
from .actions import CustomPublishAction
from .models import Post, Comment
from .models import Post, Comment, Event, EventGuide
class CommentInline(djadmin2.Admin2Inline):
@ -25,7 +29,10 @@ class PostAdmin(djadmin2.ModelAdmin2):
list_actions = [DeleteSelectedAction, CustomPublishAction, unpublish_items]
inlines = [CommentInline]
search_fields = ('title', '^body')
list_filter = ['published', 'title']
list_display = ('title', 'body', 'published')
field_renderers = {
'title': renderers.title_renderer,
}
class CommentAdmin(djadmin2.ModelAdmin2):
@ -33,6 +40,12 @@ class CommentAdmin(djadmin2.ModelAdmin2):
list_filter = ['post', ]
class EventAdmin(djadmin2.ModelAdmin2):
list_display = ('date',)
# Register each model with the admin
djadmin2.default.register(Post, PostAdmin)
djadmin2.default.register(Comment, CommentAdmin)
djadmin2.default.register(Event, EventAdmin)
djadmin2.default.register(EventGuide)

View file

@ -52,7 +52,7 @@ class CommentListTest(BaseIntegrationTest):
class PostListTest(BaseIntegrationTest):
def test_view_ok(self):
post = Post.objects.create(title="a_post_title", body="body")
post = Post.objects.create(title="A Post Title", body="body")
response = self.client.get(reverse("admin2:blog_post_index"))
self.assertContains(response, post.title)
@ -67,33 +67,53 @@ class PostListTest(BaseIntegrationTest):
self.assertInHTML('<a tabindex="-1" href="#" data-name="action" data-value="DeleteSelectedAction">Delete selected items</a>', response.content)
def test_delete_selected_post(self):
post = Post.objects.create(title="a_post_title", body="body")
post = Post.objects.create(title="A Post Title", body="body")
params = {'action': 'DeleteSelectedAction', 'selected_model_pk': str(post.pk)}
response = self.client.post(reverse("admin2:blog_post_index"), params)
# caution : uses pluralization
self.assertInHTML('<p>Are you sure you want to delete the selected post? The following item will be deleted:</p>', response.content)
def test_delete_selected_post_confirmation(self):
post = Post.objects.create(title="a_post_title", body="body")
post = Post.objects.create(title="A Post Title", body="body")
params = {'action': 'DeleteSelectedAction', 'selected_model_pk': str(post.pk), 'confirmed': 'yes'}
response = self.client.post(reverse("admin2:blog_post_index"), params)
self.assertRedirects(response, reverse("admin2:blog_post_index"))
def test_delete_selected_post_none_selected(self):
Post.objects.create(title="a_post_title", body="body")
Post.objects.create(title="A Post Title", body="body")
params = {'action': 'DeleteSelectedAction'}
response = self.client.post(reverse("admin2:blog_post_index"), params, follow=True)
self.assertContains(response, "Items must be selected in order to perform actions on them. No items have been changed.")
def test_search_posts(self):
Post.objects.create(title="a_post_title", body="body")
Post.objects.create(title="another_post_title", body="body")
Post.objects.create(title="post_with_keyword_in_body", body="another post body")
Post.objects.create(title="A Post Title", body="body")
Post.objects.create(title="Another Post Title", body="body")
Post.objects.create(title="Post With Keyword In Body", body="another post body")
params = {"q":"another"}
response = self.client.get(reverse("admin2:blog_post_index"), params)
self.assertContains(response, "another_post_title")
self.assertContains(response, "post_with_keyword_in_body")
self.assertNotContains(response, "a_post_title")
self.assertContains(response, "Another Post Title")
self.assertContains(response, "Post With Keyword In Body")
self.assertNotContains(response, "A Post Title")
def test_renderer_title(self):
Post.objects.create(title='a lowercase title', body='body', published=False)
response = self.client.get(reverse('admin2:blog_post_index'))
self.assertContains(response, 'A Lowercase Title')
def test_renderer_body(self):
Post.objects.create(title='title', body='a lowercase body', published=False)
response = self.client.get(reverse('admin2:blog_post_index'))
self.assertContains(response, 'a lowercase body')
def test_renderer_unpublished(self):
Post.objects.create(title='title', body='body', published=False)
response = self.client.get(reverse('admin2:blog_post_index'))
self.assertContains(response, 'icon-minus-sign')
def test_renderer_published(self):
Post.objects.create(title='title', body='body', published=True)
response = self.client.get(reverse('admin2:blog_post_index'))
self.assertContains(response, 'icon-ok-sign')
class PostListTestCustomAction(BaseIntegrationTest):
@ -103,7 +123,7 @@ class PostListTestCustomAction(BaseIntegrationTest):
self.assertInHTML('<a tabindex="-1" href="#" data-name="action" data-value="CustomPublishAction">Publish selected items</a>', response.content)
def test_publish_selected_items(self):
post = Post.objects.create(title="a_post_title",
post = Post.objects.create(title="A Post Title",
body="body",
published=False)
self.assertEqual(Post.objects.filter(published=True).count(), 0)
@ -121,7 +141,7 @@ class PostListTestCustomAction(BaseIntegrationTest):
self.assertInHTML('<a tabindex="-1" href="#" data-name="action" data-value="unpublish_items">Unpublish selected items</a>', response.content)
def test_unpublish_selected_items(self):
post = Post.objects.create(title="a_post_title",
post = Post.objects.create(title="A Post Title",
body="body",
published=True)
self.assertEqual(Post.objects.filter(published=True).count(), 1)
@ -136,7 +156,7 @@ class PostListTestCustomAction(BaseIntegrationTest):
class PostDetailViewTest(BaseIntegrationTest):
def test_view_ok(self):
post = Post.objects.create(title="a_post_title", body="body")
post = Post.objects.create(title="A Post Title", body="body")
response = self.client.get(reverse("admin2:blog_post_detail",
args=(post.pk, )))
self.assertContains(response, post.title)
@ -153,15 +173,15 @@ class PostCreateViewTest(BaseIntegrationTest):
"comment_set-INITIAL_FORMS": u'0',
"comment_set-MAX_NUM_FORMS": u'',
"comment_set-0-body": u'Comment Body',
"title": "a_post_title",
"title": "A Post Title",
"body": "a_post_body",
}
response = self.client.post(reverse("admin2:blog_post_create"),
post_data,
follow=True)
self.assertTrue(Post.objects.filter(title="a_post_title").exists())
Post.objects.get(title="a_post_title")
self.assertTrue(Post.objects.filter(title="A Post Title").exists())
Post.objects.get(title="A Post Title")
Comment.objects.get(body="Comment Body")
self.assertRedirects(response, reverse("admin2:blog_post_index"))
@ -174,14 +194,14 @@ class PostCreateViewTest(BaseIntegrationTest):
"comment_set-TOTAL_FORMS": u'2',
"comment_set-INITIAL_FORMS": u'0',
"comment_set-MAX_NUM_FORMS": u'',
"title": "a_post_title",
"title": "A Post Title",
"body": "a_post_body",
"_addanother": ""
}
self.client.login(username='admin', password='password')
response = self.client.post(reverse("admin2:blog_post_create"),
post_data)
Post.objects.get(title='a_post_title')
Post.objects.get(title='A Post Title')
self.assertRedirects(response, reverse("admin2:blog_post_create"))
def test_save_and_continue_editing_redirects_to_update(self):
@ -206,13 +226,13 @@ class PostCreateViewTest(BaseIntegrationTest):
class PostDeleteViewTest(BaseIntegrationTest):
def test_view_ok(self):
post = Post.objects.create(title="a_post_title", body="body")
post = Post.objects.create(title="A Post Title", body="body")
response = self.client.get(reverse("admin2:blog_post_delete",
args=(post.pk, )))
self.assertContains(response, post.title)
def test_delete_post(self):
post = Post.objects.create(title="a_post_title", body="body")
post = Post.objects.create(title="A Post Title", body="body")
response = self.client.post(reverse("admin2:blog_post_delete",
args=(post.pk, )))
self.assertRedirects(response, reverse("admin2:blog_post_index"))