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"))