mirror of
https://github.com/Hopiu/wagtail.git
synced 2026-05-09 16:04:45 +00:00
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:
commit
d36fe2b253
11 changed files with 258 additions and 88 deletions
|
|
@ -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
|
||||
===================
|
||||
|
||||
|
|
|
|||
|
|
@ -1 +1,4 @@
|
|||
from .conf import APIField # noqa
|
||||
|
||||
|
||||
default_app_config = 'wagtail.api.apps.WagtailAPIAppConfig'
|
||||
|
|
|
|||
13
wagtail/api/conf.py
Normal file
13
wagtail/api/conf.py
Normal 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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 + [
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
45
wagtail/wagtailimages/api/fields.py
Normal file
45
wagtail/wagtailimages/api/fields.py
Normal 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'),
|
||||
])
|
||||
Loading…
Reference in a new issue