diff --git a/djadmin2/renderers.py b/djadmin2/renderers.py
new file mode 100644
index 0000000..96fb417
--- /dev/null
+++ b/djadmin2/renderers.py
@@ -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)
diff --git a/djadmin2/settings.py b/djadmin2/settings.py
index 4df39e5..d0ae587 100644
--- a/djadmin2/settings.py
+++ b/djadmin2/settings.py
@@ -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',
diff --git a/djadmin2/templates/djadmin2/bootstrap/model_list.html b/djadmin2/templates/djadmin2/bootstrap/model_list.html
index 2e09eb9..32bfc42 100644
--- a/djadmin2/templates/djadmin2/bootstrap/model_list.html
+++ b/djadmin2/templates/djadmin2/bootstrap/model_list.html
@@ -84,12 +84,12 @@
{% for attr in view.model_admin.list_display %}
{% if permissions.has_change_permission %}
- {% get_attr obj attr %}
+ {% render obj attr %}
{% else %}
{% if permissions.has_view_permission %}
- {% get_attr obj attr %}
+ {% render obj attr %}
{% else %}
- {% get_attr obj attr %}
+ {% render obj attr %}
{% endif %}
{% endif %}
|
diff --git a/djadmin2/templates/djadmin2/bootstrap/renderers/boolean.html b/djadmin2/templates/djadmin2/bootstrap/renderers/boolean.html
new file mode 100644
index 0000000..4edf8c8
--- /dev/null
+++ b/djadmin2/templates/djadmin2/bootstrap/renderers/boolean.html
@@ -0,0 +1,5 @@
+{% if value %}
+
+{% else %}
+
+{% endif %}
diff --git a/djadmin2/templatetags/admin2_tags.py b/djadmin2/templatetags/admin2_tags.py
index 48f45b2..f105438 100644
--- a/djadmin2/templatetags/admin2_tags.py
+++ b/djadmin2/templatetags/admin2_tags.py
@@ -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)
diff --git a/djadmin2/tests/__init__.py b/djadmin2/tests/__init__.py
index 4ad9fa3..f0a8411 100644
--- a/djadmin2/tests/__init__.py
+++ b/djadmin2/tests/__init__.py
@@ -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 *
diff --git a/djadmin2/tests/test_admin2tags.py b/djadmin2/tests/test_admin2tags.py
index a2c3cfc..76018c9 100644
--- a/djadmin2/tests/test_admin2tags.py
+++ b/djadmin2/tests/test_admin2tags.py
@@ -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"
- )
diff --git a/djadmin2/tests/test_renderers.py b/djadmin2/tests/test_renderers.py
new file mode 100644
index 0000000..d1689ac
--- /dev/null
+++ b/djadmin2/tests/test_renderers.py
@@ -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)
diff --git a/djadmin2/tests/test_utils.py b/djadmin2/tests/test_utils.py
index 1cd6c60..8020ee9 100644
--- a/djadmin2/tests/test_utils.py
+++ b/djadmin2/tests/test_utils.py
@@ -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"
+ )
diff --git a/djadmin2/types.py b/djadmin2/types.py
index ce5694d..64ecafe 100644
--- a/djadmin2/types.py
+++ b/djadmin2/types.py
@@ -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
diff --git a/djadmin2/utils.py b/djadmin2/utils.py
index 18a33f3..fc2f869 100644
--- a/djadmin2/utils.py
+++ b/djadmin2/utils.py
@@ -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
diff --git a/docs/index.rst b/docs/index.rst
index 10f569a..ec3a21b 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -80,6 +80,7 @@ Reference
ref/permissions
ref/views
ref/built-in-views
+ ref/renderers
ref/meta
Indices and tables
diff --git a/docs/ref/renderers.rst b/docs/ref/renderers.rst
new file mode 100644
index 0000000..a5ad395
--- /dev/null
+++ b/docs/ref/renderers.rst
@@ -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:
diff --git a/example/blog/admin2.py b/example/blog/admin2.py
index 8e1a946..2cb9754 100644
--- a/example/blog/admin2.py
+++ b/example/blog/admin2.py
@@ -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)
diff --git a/example/blog/tests/test_views.py b/example/blog/tests/test_views.py
index 2a77a58..6d87649 100644
--- a/example/blog/tests/test_views.py
+++ b/example/blog/tests/test_views.py
@@ -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('Delete selected items', 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('Are you sure you want to delete the selected post? The following item will be deleted:
', 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('Publish selected items', 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('Unpublish selected items', 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"))