First pass on nested serializers in HTML

This commit is contained in:
Tom Christie 2014-10-09 15:11:19 +01:00
parent babdc78e61
commit 5d247a65c8
12 changed files with 195 additions and 28 deletions

View File

@ -26,11 +26,13 @@ Create a new Django project named `tutorial`, then start a new app called `quick
Now sync your database for the first time: Now sync your database for the first time:
python manage.py syncdb python manage.py migrate
Make sure to create an initial user named `admin` with a password of `password`. We'll authenticate as that user later in our example. We'll also create an initial user named `admin` with a password of `password`. We'll authenticate as that user later in our example.
Once you've set up a database and got everything synced and ready to go, open up the app's directory and we'll get coding... python manage.py createsuperuser
Once you've set up a database and initial user created and ready to go, open up the app's directory and we'll get coding...
## Serializers ## Serializers

View File

@ -114,12 +114,15 @@ else:
# MinValueValidator and MaxValueValidator only accept `message` in 1.8+ # MinValueValidator, MaxValueValidator et al. only accept `message` in 1.8+
if django.VERSION >= (1, 8): if django.VERSION >= (1, 8):
from django.core.validators import MinValueValidator, MaxValueValidator from django.core.validators import MinValueValidator, MaxValueValidator
from django.core.validators import MinLengthValidator, MaxLengthValidator
else: else:
from django.core.validators import MinValueValidator as DjangoMinValueValidator from django.core.validators import MinValueValidator as DjangoMinValueValidator
from django.core.validators import MaxValueValidator as DjangoMaxValueValidator from django.core.validators import MaxValueValidator as DjangoMaxValueValidator
from django.core.validators import MinLengthValidator as DjangoMinLengthValidator
from django.core.validators import MaxLengthValidator as DjangoMaxLengthValidator
class MinValueValidator(DjangoMinValueValidator): class MinValueValidator(DjangoMinValueValidator):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@ -131,6 +134,17 @@ else:
self.message = kwargs.pop('message', self.message) self.message = kwargs.pop('message', self.message)
super(MaxValueValidator, self).__init__(*args, **kwargs) super(MaxValueValidator, self).__init__(*args, **kwargs)
class MinLengthValidator(DjangoMinLengthValidator):
def __init__(self, *args, **kwargs):
self.message = kwargs.pop('message', self.message)
super(MinLengthValidator, self).__init__(*args, **kwargs)
class MaxLengthValidator(DjangoMaxLengthValidator):
def __init__(self, *args, **kwargs):
self.message = kwargs.pop('message', self.message)
super(MaxLengthValidator, self).__init__(*args, **kwargs)
# URLValidator only accepts `message` in 1.6+ # URLValidator only accepts `message` in 1.6+
if django.VERSION >= (1, 6): if django.VERSION >= (1, 6):
from django.core.validators import URLValidator from django.core.validators import URLValidator

View File

@ -8,7 +8,10 @@ from django.utils.dateparse import parse_date, parse_datetime, parse_time
from django.utils.encoding import is_protected_type from django.utils.encoding import is_protected_type
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from rest_framework import ISO_8601 from rest_framework import ISO_8601
from rest_framework.compat import smart_text, EmailValidator, MinValueValidator, MaxValueValidator, URLValidator from rest_framework.compat import (
smart_text, EmailValidator, MinValueValidator, MaxValueValidator,
MinLengthValidator, MaxLengthValidator, URLValidator
)
from rest_framework.settings import api_settings from rest_framework.settings import api_settings
from rest_framework.utils import html, representation, humanize_datetime from rest_framework.utils import html, representation, humanize_datetime
import copy import copy
@ -138,7 +141,7 @@ class Field(object):
self.label = label self.label = label
self.help_text = help_text self.help_text = help_text
self.style = {} if style is None else style self.style = {} if style is None else style
self.validators = validators or self.default_validators[:] self.validators = validators[:] or self.default_validators[:]
self.allow_null = allow_null self.allow_null = allow_null
# These are set up by `.bind()` when the field is added to a serializer. # These are set up by `.bind()` when the field is added to a serializer.
@ -412,16 +415,24 @@ class NullBooleanField(Field):
class CharField(Field): class CharField(Field):
default_error_messages = { default_error_messages = {
'blank': _('This field may not be blank.') 'blank': _('This field may not be blank.'),
'max_length': _('Ensure this field has no more than {max_length} characters.'),
'min_length': _('Ensure this field has no more than {min_length} characters.')
} }
initial = '' initial = ''
coerce_blank_to_null = False coerce_blank_to_null = False
def __init__(self, **kwargs): def __init__(self, **kwargs):
self.allow_blank = kwargs.pop('allow_blank', False) self.allow_blank = kwargs.pop('allow_blank', False)
self.max_length = kwargs.pop('max_length', None) max_length = kwargs.pop('max_length', None)
self.min_length = kwargs.pop('min_length', None) min_length = kwargs.pop('min_length', None)
super(CharField, self).__init__(**kwargs) super(CharField, self).__init__(**kwargs)
if max_length is not None:
message = self.error_messages['max_length'].format(max_length=max_length)
self.validators.append(MaxLengthValidator(max_length, message=message))
if min_length is not None:
message = self.error_messages['min_length'].format(min_length=min_length)
self.validators.append(MinLengthValidator(min_length, message=message))
def run_validation(self, data=empty): def run_validation(self, data=empty):
# Test for the empty string here so that it does not get validated, # Test for the empty string here so that it does not get validated,
@ -857,6 +868,13 @@ class MultipleChoiceField(ChoiceField):
} }
default_empty_html = [] default_empty_html = []
def get_value(self, dictionary):
# We override the default field access in order to support
# lists in HTML forms.
if html.is_html_input(dictionary):
return dictionary.getlist(self.field_name)
return dictionary.get(self.field_name, empty)
def to_internal_value(self, data): def to_internal_value(self, data):
if isinstance(data, type('')) or not hasattr(data, '__iter__'): if isinstance(data, type('')) or not hasattr(data, '__iter__'):
self.fail('not_a_list', input_type=type(data).__name__) self.fail('not_a_list', input_type=type(data).__name__)

View File

@ -1,6 +1,7 @@
from rest_framework.compat import smart_text, urlparse from rest_framework.compat import smart_text, urlparse
from rest_framework.fields import empty, Field from rest_framework.fields import empty, Field
from rest_framework.reverse import reverse from rest_framework.reverse import reverse
from rest_framework.utils import html
from django.core.exceptions import ObjectDoesNotExist, ImproperlyConfigured from django.core.exceptions import ObjectDoesNotExist, ImproperlyConfigured
from django.core.urlresolvers import resolve, get_script_prefix, NoReverseMatch, Resolver404 from django.core.urlresolvers import resolve, get_script_prefix, NoReverseMatch, Resolver404
from django.db.models.query import QuerySet from django.db.models.query import QuerySet
@ -263,6 +264,13 @@ class ManyRelation(Field):
super(ManyRelation, self).__init__(*args, **kwargs) super(ManyRelation, self).__init__(*args, **kwargs)
self.child_relation.bind(field_name='', parent=self) self.child_relation.bind(field_name='', parent=self)
def get_value(self, dictionary):
# We override the default field access in order to support
# lists in HTML forms.
if html.is_html_input(dictionary):
return dictionary.getlist(self.field_name)
return dictionary.get(self.field_name, empty)
def to_internal_value(self, data): def to_internal_value(self, data):
return [ return [
self.child_relation.to_internal_value(item) self.child_relation.to_internal_value(item)
@ -278,10 +286,16 @@ class ManyRelation(Field):
@property @property
def choices(self): def choices(self):
queryset = self.child_relation.queryset
iterable = queryset.all() if (hasattr(queryset, 'all')) else queryset
items_and_representations = [
(item, self.child_relation.to_representation(item))
for item in iterable
]
return dict([ return dict([
( (
str(self.child_relation.to_representation(item)), str(item_representation),
str(item) str(item) + ' - ' + str(item_representation)
) )
for item in self.child_relation.queryset.all() for item, item_representation in items_and_representations
]) ])

View File

@ -364,6 +364,12 @@ class HTMLFormRenderer(BaseRenderer):
serializers.ManyRelation: { serializers.ManyRelation: {
'default': 'select_multiple.html', 'default': 'select_multiple.html',
'checkbox': 'select_checkbox.html' 'checkbox': 'select_checkbox.html'
},
serializers.Serializer: {
'default': 'fieldset.html'
},
serializers.ListSerializer: {
'default': 'list_fieldset.html'
} }
}) })
@ -392,7 +398,9 @@ class HTMLFormRenderer(BaseRenderer):
template = loader.get_template(template_name) template = loader.get_template(template_name)
context = Context({ context = Context({
'field': field, 'field': field,
'input_type': input_type 'input_type': input_type,
'renderer': self,
'layout': layout
}) })
return template.render(context) return template.render(context)

View File

@ -166,14 +166,25 @@ class BoundField(object):
Returned when iterating over a serializer instance, Returned when iterating over a serializer instance,
providing an API similar to Django forms and form fields. providing an API similar to Django forms and form fields.
""" """
def __init__(self, field, value, errors): def __init__(self, field, value, errors, prefix=''):
self._field = field self._field = field
self.value = value self.value = value
self.errors = errors self.errors = errors
self.name = prefix + self.field_name
def __getattr__(self, attr_name): def __getattr__(self, attr_name):
return getattr(self._field, attr_name) return getattr(self._field, attr_name)
def __iter__(self):
for field in self.fields.values():
yield self[field.field_name]
def __getitem__(self, key):
field = self.fields[key]
value = self.value.get(key) if self.value else None
error = self.errors.get(key) if self.errors else None
return BoundField(field, value, error, prefix=self.name + '.')
@property @property
def _proxy_class(self): def _proxy_class(self):
return self._field.__class__ return self._field.__class__
@ -355,16 +366,23 @@ class Serializer(BaseSerializer):
def validate(self, attrs): def validate(self, attrs):
return attrs return attrs
def __iter__(self):
errors = self.errors if hasattr(self, '_errors') else {}
for field in self.fields.values():
value = self.data.get(field.field_name) if self.data else None
error = errors.get(field.field_name)
yield BoundField(field, value, error)
def __repr__(self): def __repr__(self):
return representation.serializer_repr(self, indent=1) return representation.serializer_repr(self, indent=1)
# The following are used for accessing `BoundField` instances on the
# serializer, for the purposes of presenting a form-like API onto the
# field values and field errors.
def __iter__(self):
for field in self.fields.values():
yield self[field.field_name]
def __getitem__(self, key):
field = self.fields[key]
value = self.data.get(key)
error = self.errors.get(key) if hasattr(self, '_errors') else None
return BoundField(field, value, error)
# There's some replication of `ListField` here, # There's some replication of `ListField` here,
# but that's probably better than obfuscating the call hierarchy. # but that's probably better than obfuscating the call hierarchy.
@ -404,8 +422,9 @@ class ListSerializer(BaseSerializer):
""" """
List of object instances -> List of dicts of primitive datatypes. List of object instances -> List of dicts of primitive datatypes.
""" """
iterable = data.all() if (hasattr(data, 'all')) else data
return ReturnList( return ReturnList(
[self.child.to_representation(item) for item in data], [self.child.to_representation(item) for item in iterable],
serializer=self serializer=self
) )

View File

@ -1,10 +1,11 @@
{% load rest_framework %}
<fieldset> <fieldset>
{% if field.label %} {% if field.label %}
<div class="form-group" style="border-bottom: 1px solid #e5e5e5"> <div class="form-group" style="border-bottom: 1px solid #e5e5e5">
<legend class="control-label col-sm-2 {% if field.style.hide_label %}sr-only{% endif %}" style="border-bottom: 0">{{ field.label }}</legend> <legend class="control-label col-sm-2 {% if field.style.hide_label %}sr-only{% endif %}" style="border-bottom: 0">{{ field.label }}</legend>
</div> </div>
{% endif %} {% endif %}
{% for field_item in field.value.field_items.values() %} {% for nested_field in field %}
{{ renderer.render_field(field_item, layout=layout) }} {% render_field nested_field layout=layout renderer=renderer %}
{% endfor %} {% endfor %}
</fieldset> </fieldset>

View File

@ -0,0 +1,13 @@
{% load rest_framework %}
<fieldset>
{% if field.label %}
<div class="form-group" style="border-bottom: 1px solid #e5e5e5">
<legend class="control-label col-sm-2 {% if field.style.hide_label %}sr-only{% endif %}" style="border-bottom: 0">{{ field.label }}</legend>
</div>
{% endif %}
<ul>
{% for child in field.value %}
<li>TODO</li>
{% endfor %}
</ul>
</fieldset>

View File

@ -1,3 +1,4 @@
{% for field_item in field.value.field_items.values() %} {% load rest_framework %}
{{ renderer.render_field(field_item, layout=layout) }} {% for nested_field in field %}
{% render_field nested_field layout=layout renderer=renderer %}
{% endfor %} {% endfor %}

View File

@ -1,6 +1,7 @@
{% load rest_framework %}
<fieldset> <fieldset>
{% if field.label %}<legend {% if field.style.hide_label %}class="sr-only"{% endif %}>{{ field.label }}</legend>{% endif %} {% if field.label %}<legend {% if field.style.hide_label %}class="sr-only"{% endif %}>{{ field.label }}</legend>{% endif %}
{% for field_item in field.value.field_items.values() %} {% for nested_field in field %}
{{ renderer.render_field(field_item, layout=layout) }} {% render_field nested_field layout=layout renderer=renderer %}
{% endfor %} {% endfor %}
</fieldset> </fieldset>

View File

@ -0,0 +1,7 @@
<fieldset>
{% if field.label %}<legend {% if field.style.hide_label %}class="sr-only"{% endif %}>{{ field.label }}</legend>{% endif %}
<!-- {% if field.label %}<legend {% if field.style.hide_label %}class="sr-only"{% endif %}>{{ field.label }}</legend>{% endif %}
{% for field_item in field.value.field_items.values() %}
{{ renderer.render_field(field_item, layout=layout) }}
{% endfor %} -->
</fieldset>

View File

@ -0,0 +1,69 @@
from rest_framework import serializers
class TestSimpleBoundField:
def test_empty_bound_field(self):
class ExampleSerializer(serializers.Serializer):
text = serializers.CharField(max_length=100)
amount = serializers.IntegerField()
serializer = ExampleSerializer()
assert serializer['text'].value == ''
assert serializer['text'].errors is None
assert serializer['text'].name == 'text'
assert serializer['amount'].value is None
assert serializer['amount'].errors is None
assert serializer['amount'].name == 'amount'
def test_populated_bound_field(self):
class ExampleSerializer(serializers.Serializer):
text = serializers.CharField(max_length=100)
amount = serializers.IntegerField()
serializer = ExampleSerializer(data={'text': 'abc', 'amount': 123})
assert serializer['text'].value == 'abc'
assert serializer['text'].errors is None
assert serializer['text'].name == 'text'
assert serializer['amount'].value is 123
assert serializer['amount'].errors is None
assert serializer['amount'].name == 'amount'
def test_error_bound_field(self):
class ExampleSerializer(serializers.Serializer):
text = serializers.CharField(max_length=100)
amount = serializers.IntegerField()
serializer = ExampleSerializer(data={'text': 'x' * 1000, 'amount': 123})
serializer.is_valid()
assert serializer['text'].value == 'x' * 1000
assert serializer['text'].errors == ['Ensure this field has no more than 100 characters.']
assert serializer['text'].name == 'text'
assert serializer['amount'].value is 123
assert serializer['amount'].errors is None
assert serializer['amount'].name == 'amount'
class TestNestedBoundField:
def test_nested_empty_bound_field(self):
class Nested(serializers.Serializer):
more_text = serializers.CharField(max_length=100)
amount = serializers.IntegerField()
class ExampleSerializer(serializers.Serializer):
text = serializers.CharField(max_length=100)
nested = Nested()
serializer = ExampleSerializer()
assert serializer['text'].value == ''
assert serializer['text'].errors is None
assert serializer['text'].name == 'text'
assert serializer['nested']['more_text'].value == ''
assert serializer['nested']['more_text'].errors is None
assert serializer['nested']['more_text'].name == 'nested.more_text'
assert serializer['nested']['amount'].value is None
assert serializer['nested']['amount'].errors is None
assert serializer['nested']['amount'].name == 'nested.amount'