Merge pull request #3462 from kaedroho/feature/images-in-api-simple

Allow custom field classes to be used in api_fields
This commit is contained in:
Bertrand Bordage 2017-04-06 19:41:29 +02:00 committed by GitHub
commit d36fe2b253
11 changed files with 258 additions and 88 deletions

View file

@ -111,11 +111,15 @@ For example:
# blog/models.py
from wagtail.api import APIField
class BlogPageAuthor(Orderable):
page = models.ForeignKey('blog.BlogPage', related_name='authors')
name = models.CharField(max_length=255)
api_fields = ['name']
api_fields = [
APIField('name'),
]
class BlogPage(Page):
@ -126,10 +130,10 @@ For example:
# Export fields over the API
api_fields = [
'published_date',
'body',
'feed_image',
'authors', # This will nest the relevant BlogPageAuthor objects in the API response
APIField('published_date'),
APIField('body'),
APIField('feed_image'),
APIField('authors'), # This will nest the relevant BlogPageAuthor objects in the API response
]
This will make ``published_date``, ``body``, ``feed_image`` and a list of
@ -137,6 +141,110 @@ This will make ``published_date``, ``body``, ``feed_image`` and a list of
fields, you must select the ``blog.BlogPage`` type using the ``?type``
:ref:`parameter in the API itself <apiv2_custom_page_fields>`.
Custom serialisers
------------------
.. versionadded: 1.10
Serialisers_ are used to convert the database representation of a model into
JSON format. You can override the serialiser for any field using the
``serializer`` keyword argument:
.. code-block:: python
from rest_framework.fields import DateField
class BlogPage(Page):
...
api_fields = [
# Change the format of the published_date field to "Thursday 06 April 2017"
APIField('published_date', serializer=DateField(format='%A $d %B %Y')),
...
]
Django REST framework's serializers can all take a source_ argument allowing you
to add API fields that have a different field name or no underlying field at all:
.. code-block:: python
from rest_framework.fields import DateField
class BlogPage(Page):
...
api_fields = [
# Date in ISO8601 format (the default)
APIField('published_date'),
# A separate published_date_display field with a different format
APIField('published_date_display', serializer=DateField(format='%A $d %B %Y', source='published_date')),
...
]
This adds two fields to the API (other fields omitted for brevity):
.. code-block:: json
{
"published_date": "2017-04-06",
"published_date_display": "Thursday 06 April 2017"
}
.. _Serialisers: http://www.django-rest-framework.org/api-guide/fields/
.. _source: http://www.django-rest-framework.org/api-guide/fields/#source
Images in the API
-----------------
.. versionadded: 1.10
The :class:`~wagtail.wagtailimages.api.fields.ImageRenditionField` serialiser
allows you to add renditions of images into your API. It requires an image
filter string specifying the resize operations to perform on the image. It can
also take the ``source`` keyword argument described above.
For example:
.. code-block:: python
from wagtail.wagtailimages.api.fields.ImageRenditionField
class BlogPage(Page):
...
api_fields = [
# Adds information about the source image (eg, title) into the API
APIField('feed_image'),
# Adds a URL to a rendered thumbnail of the image to the API
APIField('feed_image_thumbnail', serializer=ImageRenditionField('fill-100x100', source='feed_image')),
...
]
This would add the following to the JSON:
.. code-block:: json
{
"feed_image": {
"id": 45529,
"meta": {
"type": "wagtailimages.Image",
"detail_url": "http://www.example.com/api/v2/images/12/",
"tags": []
},
"title": "A test image",
"width": 2000,
"height": 1125
},
"feed_image_thumbnail": {
"url": "http://www.example.com/media/images/a_test_image.fill-100x100.jpg",
"width": 100,
"height": 100
}
}
Additional settings
===================

View file

@ -1 +1,4 @@
from .conf import APIField # noqa
default_app_config = 'wagtail.api.apps.WagtailAPIAppConfig'

13
wagtail/api/conf.py Normal file
View file

@ -0,0 +1,13 @@
from __future__ import absolute_import, unicode_literals
class APIField(object):
def __init__(self, name, serializer=None):
self.name = name
self.serializer = serializer
def __hash__(self):
return hash(self.name)
def __repr__(self):
return '<APIField {}>'.format(self.name)

View file

@ -13,6 +13,7 @@ from rest_framework.renderers import BrowsableAPIRenderer, JSONRenderer
from rest_framework.response import Response
from rest_framework.viewsets import GenericViewSet
from wagtail.api import APIField
from wagtail.wagtailcore.models import Page
from .filters import (
@ -93,31 +94,32 @@ class BaseAPIEndpoint(GenericViewSet):
return Response(data, status=status.HTTP_400_BAD_REQUEST)
return super(BaseAPIEndpoint, self).handle_exception(exc)
@classmethod
def _convert_api_fields(cls, fields):
return [field if isinstance(field, APIField) else APIField(field)
for field in fields]
@classmethod
def get_body_fields(cls, model):
"""
This returns a list of field names that are allowed to
be used in the API (excluding the id field)
"""
fields = cls.body_fields[:]
return cls._convert_api_fields(cls.body_fields + list(getattr(model, 'api_fields', ())))
if hasattr(model, 'api_fields'):
fields.extend(model.api_fields)
return fields
@classmethod
def get_body_fields_names(cls, model):
return [field.name for field in cls.get_body_fields(model)]
@classmethod
def get_meta_fields(cls, model):
"""
This returns a list of field names that are allowed to
be used in the meta section in the API (excluding type and detail_url).
"""
meta_fields = cls.meta_fields[:]
return cls._convert_api_fields(cls.meta_fields + list(getattr(model, 'api_meta_fields', ())))
if hasattr(model, 'api_meta_fields'):
meta_fields.extend(model.api_meta_fields)
@classmethod
def get_meta_fields_names(cls, model):
return [field.name for field in cls.get_meta_fields(model)]
return meta_fields
@classmethod
def get_field_serializer_overrides(cls, model):
return {field.name: field.serializer
for field in cls.get_body_fields(model) + cls.get_meta_fields(model)
if field.serializer is not None}
@classmethod
def get_available_fields(cls, model, db_fields_only=False):
@ -129,7 +131,7 @@ class BaseAPIEndpoint(GenericViewSet):
an underlying column in the database (eg, type/detail_url and any custom
fields that are callables)
"""
fields = cls.get_body_fields(model) + cls.get_meta_fields(model)
fields = cls.get_body_fields_names(model) + cls.get_meta_fields_names(model)
if db_fields_only:
# Get list of available database fields then remove any fields in our
@ -172,8 +174,8 @@ class BaseAPIEndpoint(GenericViewSet):
@classmethod
def _get_serializer_class(cls, router, model, fields_config, show_details=False, nested=False):
# Get all available fields
body_fields = cls.get_body_fields(model)
meta_fields = cls.get_meta_fields(model)
body_fields = cls.get_body_fields_names(model)
meta_fields = cls.get_meta_fields_names(model)
all_fields = body_fields + meta_fields
# Remove any duplicates
@ -257,7 +259,15 @@ class BaseAPIEndpoint(GenericViewSet):
# Reorder fields so it matches the order of all_fields
fields = [field for field in all_fields if field in fields]
return get_serializer_class(model, fields, meta_fields=meta_fields, child_serializer_classes=child_serializer_classes, base=cls.base_serializer_class)
field_serializer_overrides = {field[0]: field[1] for field in cls.get_field_serializer_overrides(model).items() if field[0] in fields}
return get_serializer_class(
model,
fields,
meta_fields=meta_fields,
field_serializer_overrides=field_serializer_overrides,
child_serializer_classes=child_serializer_classes,
base=cls.base_serializer_class
)
def get_serializer_class(self):
request = self.request

View file

@ -337,13 +337,20 @@ class PageSerializer(BaseSerializer):
return super(PageSerializer, self).build_relational_field(field_name, relation_info)
def get_serializer_class(model_, fields_, meta_fields, child_serializer_classes=None, base=BaseSerializer):
def get_serializer_class(model, field_names, meta_fields, field_serializer_overrides=None, child_serializer_classes=None, base=BaseSerializer):
model_ = model
class Meta:
model = model_
fields = list(fields_)
fields = list(field_names)
return type(str(model_.__name__ + 'Serializer'), (base, ), {
attrs = {
'Meta': Meta,
'meta_fields': list(meta_fields),
'child_serializer_classes': child_serializer_classes or {},
})
}
if field_serializer_overrides:
attrs.update(field_serializer_overrides)
return type(str(model_.__name__ + 'Serializer'), (base, ), attrs)

View file

@ -188,7 +188,7 @@ class TestPageListing(TestCase):
content = json.loads(response.content.decode('UTF-8'))
for page in content['items']:
self.assertEqual(set(page.keys()), {'id', 'meta', 'title', 'date', 'related_links', 'tags', 'carousel_items', 'body', 'feed_image'})
self.assertEqual(set(page.keys()), {'id', 'meta', 'title', 'date', 'related_links', 'tags', 'carousel_items', 'body', 'feed_image', 'feed_image_thumbnail'})
self.assertEqual(set(page['meta'].keys()), {'type', 'detail_url', 'show_in_menus', 'first_published_at', 'seo_title', 'slug', 'html_url', 'search_description'})
def test_all_fields_then_remove_something(self):
@ -196,7 +196,7 @@ class TestPageListing(TestCase):
content = json.loads(response.content.decode('UTF-8'))
for page in content['items']:
self.assertEqual(set(page.keys()), {'id', 'meta', 'related_links', 'tags', 'carousel_items', 'body', 'feed_image'})
self.assertEqual(set(page.keys()), {'id', 'meta', 'related_links', 'tags', 'carousel_items', 'body', 'feed_image', 'feed_image_thumbnail'})
self.assertEqual(set(page['meta'].keys()), {'type', 'detail_url', 'show_in_menus', 'first_published_at', 'slug', 'html_url', 'search_description'})
def test_remove_all_fields(self):
@ -809,6 +809,12 @@ class TestPageDetail(TestCase):
self.assertEqual(content['feed_image']['meta']['type'], 'wagtailimages.Image')
self.assertEqual(content['feed_image']['meta']['detail_url'], 'http://localhost/api/v2beta/images/7/')
# Check that the feed images' thumbnail was serialised properly
self.assertEqual(content['feed_image_thumbnail'], {
# This is OK because it tells us it used ImageRenditionField to generate the output
'error': 'SourceImageIOError'
})
# Check that the child relations were serialised properly
self.assertEqual(content['related_links'], [])
for carousel_item in content['carousel_items']:
@ -838,6 +844,7 @@ class TestPageDetail(TestCase):
'tags',
'date',
'feed_image',
'feed_image_thumbnail',
'carousel_items',
'related_links',
]

View file

@ -1,5 +1,6 @@
from __future__ import absolute_import, unicode_literals
import warnings
from collections import OrderedDict
from django.conf.urls import url
@ -10,6 +11,7 @@ from rest_framework.renderers import BrowsableAPIRenderer, JSONRenderer
from rest_framework.response import Response
from rest_framework.viewsets import GenericViewSet
from wagtail.api import APIField
from wagtail.wagtailcore.models import Page
from wagtail.wagtailcore.utils import resolve_model_string
from wagtail.wagtaildocs.models import get_document_model
@ -80,7 +82,23 @@ class BaseAPIEndpoint(GenericViewSet):
if hasattr(model, 'api_fields'):
api_fields.extend(model.api_fields)
return api_fields
# Remove any new-style API field configs (only supported in v2)
def convert_api_fields(fields):
for field in fields:
if isinstance(field, APIField):
warnings.warn(
"class-based api_fields are not supported by the v1 API module. "
"Please update the .api_fields attribute of {}.{} or update to the "
"v2 API.".format(model._meta.app_label, model.__name__)
)
# Ignore fields with custom serializers
if field.serializer is None:
yield field.name
else:
yield field
return list(convert_api_fields(api_fields))
def check_query_parameters(self, queryset):
"""

View file

@ -7,12 +7,14 @@ from modelcluster.contrib.taggit import ClusterTaggableManager
from modelcluster.fields import ParentalKey
from taggit.models import TaggedItemBase
from wagtail.api import APIField
from wagtail.utils.pagination import paginate
from wagtail.wagtailadmin.edit_handlers import (
FieldPanel, InlinePanel, MultiFieldPanel, PageChooserPanel)
from wagtail.wagtailcore.fields import RichTextField
from wagtail.wagtailcore.models import Orderable, Page
from wagtail.wagtaildocs.edit_handlers import DocumentChooserPanel
from wagtail.wagtailimages.api.fields import ImageRenditionField
from wagtail.wagtailimages.edit_handlers import ImageChooserPanel
from wagtail.wagtailsearch import index
@ -276,12 +278,13 @@ class BlogEntryPage(Page):
)
api_fields = (
'body',
'tags',
'date',
'feed_image',
'carousel_items',
'related_links',
APIField('body'),
APIField('tags'),
APIField('date'),
APIField('feed_image'),
APIField('feed_image_thumbnail', serializer=ImageRenditionField('fill-300x300', source='feed_image')),
APIField('carousel_items'),
APIField('related_links'),
)
search_fields = Page.search_fields + [

View file

@ -138,7 +138,7 @@ class TestAdminPageListing(AdminAPITestCase, TestPageListing):
content = json.loads(response.content.decode('UTF-8'))
for page in content['items']:
self.assertEqual(set(page.keys()), {'id', 'meta', 'title', 'date', 'related_links', 'tags', 'carousel_items', 'body', 'feed_image'})
self.assertEqual(set(page.keys()), {'id', 'meta', 'title', 'date', 'related_links', 'tags', 'carousel_items', 'body', 'feed_image', 'feed_image_thumbnail'})
self.assertEqual(set(page['meta'].keys()), {'type', 'detail_url', 'show_in_menus', 'first_published_at', 'seo_title', 'slug', 'parent', 'html_url', 'search_description', 'children', 'descendants', 'status', 'latest_revision_created_at'})
def test_all_fields_then_remove_something(self):
@ -146,7 +146,7 @@ class TestAdminPageListing(AdminAPITestCase, TestPageListing):
content = json.loads(response.content.decode('UTF-8'))
for page in content['items']:
self.assertEqual(set(page.keys()), {'id', 'meta', 'related_links', 'tags', 'carousel_items', 'body', 'feed_image'})
self.assertEqual(set(page.keys()), {'id', 'meta', 'related_links', 'tags', 'carousel_items', 'body', 'feed_image', 'feed_image_thumbnail'})
self.assertEqual(set(page['meta'].keys()), {'type', 'detail_url', 'show_in_menus', 'first_published_at', 'slug', 'parent', 'html_url', 'search_description', 'children', 'descendants', 'latest_revision_created_at'})
def test_all_nested_fields(self):
@ -413,6 +413,7 @@ class TestAdminPageDetail(AdminAPITestCase, TestPageDetail):
'tags',
'date',
'feed_image',
'feed_image_thumbnail',
'carousel_items',
'related_links',
'__types',

View file

@ -1,53 +1,8 @@
from __future__ import absolute_import, unicode_literals
from collections import OrderedDict
from rest_framework.fields import Field
from ...models import SourceImageIOError
from ..fields import ImageRenditionField
from ..v2.serializers import ImageSerializer
class ImageRenditionField(Field):
"""
A field that generates a rendition with the specified filter spec, and serialises
details of that rendition.
Example:
"thumbnail": {
"url": "/media/images/myimage.max-165x165.jpg",
"width": 165,
"height": 100
}
If there is an error with the source image. The dict will only contain a single
key, "error", indicating this error:
"thumbnail": {
"error": "SourceImageIOError"
}
"""
def __init__(self, filter_spec, *args, **kwargs):
self.filter_spec = filter_spec
super(ImageRenditionField, self).__init__(*args, **kwargs)
def get_attribute(self, instance):
return instance
def to_representation(self, image):
try:
thumbnail = image.get_rendition(self.filter_spec)
return OrderedDict([
('url', thumbnail.url),
('width', thumbnail.width),
('height', thumbnail.height),
])
except SourceImageIOError:
return OrderedDict([
('error', 'SourceImageIOError'),
])
class AdminImageSerializer(ImageSerializer):
thumbnail = ImageRenditionField('max-165x165', read_only=True)
thumbnail = ImageRenditionField('max-165x165', source='*', read_only=True)

View file

@ -0,0 +1,45 @@
from __future__ import absolute_import, unicode_literals
from collections import OrderedDict
from rest_framework.fields import Field
from ..models import SourceImageIOError
class ImageRenditionField(Field):
"""
A field that generates a rendition with the specified filter spec, and serialises
details of that rendition.
Example:
"thumbnail": {
"url": "/media/images/myimage.max-165x165.jpg",
"width": 165,
"height": 100
}
If there is an error with the source image. The dict will only contain a single
key, "error", indicating this error:
"thumbnail": {
"error": "SourceImageIOError"
}
"""
def __init__(self, filter_spec, *args, **kwargs):
self.filter_spec = filter_spec
super(ImageRenditionField, self).__init__(*args, **kwargs)
def to_representation(self, image):
try:
thumbnail = image.get_rendition(self.filter_spec)
return OrderedDict([
('url', thumbnail.url),
('width', thumbnail.width),
('height', thumbnail.height),
])
except SourceImageIOError:
return OrderedDict([
('error', 'SourceImageIOError'),
])