Skip to content
This repository
Browse code

Sphinx docs, examples, lots of refactoring

  • Loading branch information...
commit 4100242fa2395bef8db0c5ffbab6f5d0cf95301d 1 parent 9979903
tom christie tom@tomchristie.com authored

Showing 36 changed files with 2,240 additions and 2 deletions. Show diff stats Hide diff stats

  1. +1 1  .hgignore
  2. +4 1 docs/conf.py
  3. +5 0 docs/emitters.rst
  4. +14 0 docs/index.rst
  5. +5 0 docs/modelresource.rst
  6. +5 0 docs/parsers.rst
  7. +125 0 docs/resource.rst
  8. +5 0 docs/response.rst
  9. 0  examples/__init__.py
  10. 0  examples/blogpost/__init__.py
  11. +68 0 examples/blogpost/models.py
  12. +163 0 examples/blogpost/tests.py
  13. +11 0 examples/blogpost/urls.py
  14. +63 0 examples/blogpost/views.py
  15. +20 0 examples/initial_data.json
  16. +11 0 examples/manage.py
  17. 0  examples/objectstore/__init__.py
  18. +3 0  examples/objectstore/models.py
  19. +23 0 examples/objectstore/tests.py
  20. +6 0 examples/objectstore/urls.py
  21. +54 0 examples/objectstore/views.py
  22. +96 0 examples/settings.py
  23. +11 0 examples/urls.py
  24. 0  flywheel/__init__.py
  25. +118 0 flywheel/emitters.py
  26. +393 0 flywheel/modelresource.py
  27. +80 0 flywheel/parsers.py
  28. +436 0 flywheel/resource.py
  29. +126 0 flywheel/response.py
  30. +96 0 flywheel/templates/emitter.html
  31. +8 0 flywheel/templates/emitter.txt
  32. +3 0  flywheel/templates/emitter.xhtml
  33. 0  flywheel/templatetags/__init__.py
  34. +17 0 flywheel/templatetags/add_query_param.py
  35. +100 0 flywheel/templatetags/urlize_quoted_links.py
  36. +170 0 flywheel/utils.py
2  .hgignore
@@ -3,7 +3,7 @@ syntax: glob
3 3 *.pyc
4 4 *.db
5 5 env
6   -cache
  6 +docs-build
7 7 html
8 8 .project
9 9 .pydevproject
5 docs/conf.py
@@ -13,7 +13,9 @@
13 13
14 14 import sys, os
15 15
16   -sys.path.append(os.path.join(os.path.dirname(os.path.dirname(__file__)), 'src'))
  16 +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
  17 +sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(__file__)), 'flywheel'))
  18 +sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(__file__)), 'examples'))
17 19 import settings
18 20 from django.core.management import setup_environ
19 21 setup_environ(settings)
@@ -57,6 +59,7 @@
57 59 # The full version, including alpha/beta/rc tags.
58 60 release = '0.1'
59 61
  62 +autodoc_member_order='bysource'
60 63 # The language for content autogenerated by Sphinx. Refer to documentation
61 64 # for a list of supported languages.
62 65 #language = None
5 docs/emitters.rst
Source Rendered
... ... @@ -0,0 +1,5 @@
  1 +Emitters
  2 +========
  3 +
  4 +.. automodule:: emitters
  5 + :members:
14 docs/index.rst
Source Rendered
@@ -3,6 +3,20 @@ FlyWheel Documentation
3 3
4 4 This is the online documentation for FlyWheel - A REST framework for Django.
5 5
  6 +* Clean, simple, class-based views for Resources.
  7 +* Easy input validation using Forms and ModelForms.
  8 +* Self describing APIs, with HTML and Plain Text outputs.
  9 +
  10 +.. toctree::
  11 + :maxdepth: 1
  12 +
  13 + resource
  14 + modelresource
  15 + parsers
  16 + emitters
  17 + response
  18 +
  19 +
6 20 Indices and tables
7 21 ------------------
8 22
5 docs/modelresource.rst
Source Rendered
... ... @@ -0,0 +1,5 @@
  1 +ModelResource
  2 +=============
  3 +
  4 +.. automodule:: modelresource
  5 + :members:
5 docs/parsers.rst
Source Rendered
... ... @@ -0,0 +1,5 @@
  1 +Parsers
  2 +=======
  3 +
  4 +.. automodule:: parsers
  5 + :members:
125 docs/resource.rst
Source Rendered
... ... @@ -0,0 +1,125 @@
  1 +:mod:`resource`
  2 +===============
  3 +
  4 +The :mod:`resource` module is the core of FlyWheel. It provides the :class:`Resource` base class which handles incoming HTTP requests and maps them to method calls, performing authentication, input deserialization, input validation, output serialization.
  5 +
  6 +Resources are created by sublassing :class:`Resource`, setting a number of class attributes, and overriding one or more methods.
  7 +
  8 +:class:`Resource` class attributes
  9 +----------------------------------
  10 +
  11 +The following class attributes determine the behavior of the Resource and are intended to be overridden.
  12 +
  13 +.. attribute:: Resource.allowed_methods
  14 +
  15 + A list of the HTTP methods that the Resource supports.
  16 + HTTP requests to the resource that do not map to an allowed operation will result in a 405 Method Not Allowed response.
  17 +
  18 + Default: ``('GET',)``
  19 +
  20 +.. attribute:: Resource.anon_allowed_methods
  21 +
  22 + A list of the HTTP methods that the Resource supports for unauthenticated users.
  23 + Unauthenticated HTTP requests to the resource that do not map to an allowed operation will result in a 405 Method Not Allowed response.
  24 +
  25 + Default: ``()``
  26 +
  27 +.. attribute:: Resource.emitters
  28 +
  29 + Lists the set of emitters that the Resource supports. This determines which media types the resource can serialize it's output to. Clients can specify which media types they accept using standard HTTP content negotiation via the Accept header. (See `RFC 2616 - Sec 14.1 <http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html>`_) Clients can also override this standard content negotiation by specifying a `_format` ...
  30 +
  31 + The :mod:`emitters` module provides the :class:`BaseEmitter` class and a set of default emitters, including emitters for JSON and XML, as well as emitters for HTML and Plain Text which provide for a self documenting API.
  32 +
  33 + The ordering of the Emitters is important as it determines an order of preference.
  34 +
  35 + Default: ``(emitters.JSONEmitter, emitters.DocumentingHTMLEmitter, emitters.DocumentingXHTMLEmitter, emitters.DocumentingPlainTextEmitter, emitters.XMLEmitter)``
  36 +
  37 +.. attribute:: Resource.parsers
  38 +
  39 + Lists the set of parsers that the Resource supports. This determines which media types the resource can accept as input for incoming HTTP requests. (Typically PUT and POST requests).
  40 +
  41 + The ordering of the Parsers may be considered informative of preference but is not used ...
  42 +
  43 + Default: ``(parsers.JSONParser, parsers.XMLParser, parsers.FormParser)``
  44 +
  45 +.. attribute:: Resource.form
  46 +
  47 + If not None, this attribute should be a Django form which will be used to validate any request data.
  48 + This attribute is typically only used for POST or PUT requests to the resource.
  49 +
  50 + Deafult: ``None``
  51 +
  52 +.. attribute:: Resource.callmap
  53 +
  54 + Maps HTTP methods to function calls on the :class:`Resource`. It may be overridden in order to add support for other HTTP methods such as HEAD, OPTIONS and PATCH, or in order to map methods to different function names, for example to use a more `CRUD <http://en.wikipedia.org/wiki/Create,_read,_update_and_delete>`_ like style.
  55 +
  56 + Default: ``{ 'GET': 'get', 'POST': 'post', 'PUT': 'put', 'DELETE': 'delete' }``
  57 +
  58 +
  59 +:class:`Resource` methods
  60 +-------------------------
  61 +
  62 +.. method:: Resource.get
  63 +.. method:: Resource.post
  64 +.. method:: Resource.put
  65 +.. method:: Resource.delete
  66 +.. method:: Resource.authenticate
  67 +.. method:: Resource.reverse
  68 +
  69 +:class:`Resource` properties
  70 +----------------------------
  71 +
  72 +.. method:: Resource.name
  73 +.. method:: Resource.description
  74 +.. method:: Resource.default_emitter
  75 +.. method:: Resource.default_parser
  76 +.. method:: Resource.emitted_media_types
  77 +.. method:: Resource.parsed_media_types
  78 +
  79 +:class:`Resource` reserved parameters
  80 +-------------------------------------
  81 +
  82 +.. attribute:: Resource.ACCEPT_QUERY_PARAM
  83 +
  84 + If set, allows the default `Accept:` header content negotiation to be bypassed by setting the requested media type in a query parameter on the URL. This can be useful if it is necessary to be able to hyperlink to a given format on the Resource using standard HTML.
  85 +
  86 + Set to None to disable, or to another string value to use another name for the reserved URL query parameter.
  87 +
  88 + Default: ``_accept``
  89 +
  90 +.. attribute:: Resource.METHOD_PARAM
  91 +
  92 + If set, allows for PUT and DELETE requests to be tunneled on form POST operations, by setting a (typically hidden) form field with the method name. This allows standard HTML forms to perform method requests which would otherwise `not be supported <http://dev.w3.org/html5/spec/Overview.html#attr-fs-method>`_
  93 +
  94 + Set to None to disable, or to another string value to use another name for the reserved form field.
  95 +
  96 + Default: ``_method``
  97 +
  98 +.. attribute:: Resource.CONTENTTYPE_PARAM
  99 +
  100 + Used together with :attr:`CONTENT_PARAM`.
  101 +
  102 + If set, allows for arbitrary content types to be tunneled on form POST operations, by setting a form field with the content type. This allows standard HTML forms to perform requests with content types other those `supported by default <http://dev.w3.org/html5/spec/Overview.html#attr-fs-enctype>`_ (ie. `application/x-www-form-urlencoded`, `multipart/form-data`, and `text-plain`)
  103 +
  104 + Set to None to disable, or to another string value to use another name for the reserved form field.
  105 +
  106 + Default: ``_contenttype``
  107 +
  108 +.. attribute:: Resource.CONTENT_PARAM
  109 +
  110 + Used together with :attr:`CONTENTTYPE_PARAM`.
  111 +
  112 + Set to None to disable, or to another string value to use another name for the reserved form field.
  113 +
  114 + Default: ``_content``
  115 +
  116 +.. attribute:: Resource.CSRF_PARAM
  117 +
  118 + The name used in Django's (typically hidden) form field for `CSRF Protection <http://docs.djangoproject.com/en/dev/ref/contrib/csrf/>`_.
  119 +
  120 + Setting to None does not disable Django's CSRF middleware, but it does mean that the field name will not be treated as reserved by FlyWheel, so for example the default :class:`FormParser` will return fields with this as part of the request content, rather than ignoring them.
  121 +
  122 + Default:: ``csrfmiddlewaretoken``
  123 +
  124 +reserved params
  125 +internal methods
5 docs/response.rst
Source Rendered
... ... @@ -0,0 +1,5 @@
  1 +Response
  2 +========
  3 +
  4 +.. automodule:: response
  5 + :members:
0  examples/__init__.py
No changes.
0  examples/blogpost/__init__.py
No changes.
68 examples/blogpost/models.py
... ... @@ -0,0 +1,68 @@
  1 +from django.db import models
  2 +from django.template.defaultfilters import slugify
  3 +import uuid
  4 +
  5 +def uuid_str():
  6 + return str(uuid.uuid1())
  7 +
  8 +
  9 +RATING_CHOICES = ((0, 'Awful'),
  10 + (1, 'Poor'),
  11 + (2, 'OK'),
  12 + (3, 'Good'),
  13 + (4, 'Excellent'))
  14 +
  15 +class BlogPost(models.Model):
  16 + key = models.CharField(primary_key=True, max_length=64, default=uuid_str, editable=False)
  17 + title = models.CharField(max_length=128)
  18 + content = models.TextField()
  19 + created = models.DateTimeField(auto_now_add=True)
  20 + slug = models.SlugField(editable=False, default='')
  21 +
  22 + class Meta:
  23 + ordering = ('created',)
  24 +
  25 + @models.permalink
  26 + def get_absolute_url(self):
  27 + return ('blogpost.views.BlogPostInstance', (), {'key': self.key})
  28 +
  29 + @property
  30 + @models.permalink
  31 + def comments_url(self):
  32 + """Link to a resource which lists all comments for this blog post."""
  33 + return ('blogpost.views.CommentList', (), {'blogpost_id': self.key})
  34 +
  35 + @property
  36 + @models.permalink
  37 + def comment_url(self):
  38 + """Link to a resource which can create a comment for this blog post."""
  39 + return ('blogpost.views.CommentCreator', (), {'blogpost_id': self.key})
  40 +
  41 + def __unicode__(self):
  42 + return self.title
  43 +
  44 + def save(self, *args, **kwargs):
  45 + self.slug = slugify(self.title)
  46 + super(self.__class__, self).save(*args, **kwargs)
  47 +
  48 +
  49 +class Comment(models.Model):
  50 + blogpost = models.ForeignKey(BlogPost, editable=False, related_name='comments')
  51 + username = models.CharField(max_length=128)
  52 + comment = models.TextField()
  53 + rating = models.IntegerField(blank=True, null=True, choices=RATING_CHOICES, help_text='How did you rate this post?')
  54 + created = models.DateTimeField(auto_now_add=True)
  55 +
  56 + class Meta:
  57 + ordering = ('created',)
  58 +
  59 + @models.permalink
  60 + def get_absolute_url(self):
  61 + return ('blogpost.views.CommentInstance', (), {'blogpost': self.blogpost.key, 'id': self.id})
  62 +
  63 + @property
  64 + @models.permalink
  65 + def blogpost_url(self):
  66 + """Link to the blog post resource which this comment corresponds to."""
  67 + return ('blogpost.views.BlogPostInstance', (), {'key': self.blogpost.key})
  68 +
163 examples/blogpost/tests.py
... ... @@ -0,0 +1,163 @@
  1 +"""Test a range of REST API usage of the example application.
  2 +"""
  3 +
  4 +from django.test import TestCase
  5 +from django.core.urlresolvers import reverse
  6 +from blogpost import views
  7 +#import json
  8 +#from rest.utils import xml2dict, dict2xml
  9 +
  10 +
  11 +class AcceptHeaderTests(TestCase):
  12 + """Test correct behaviour of the Accept header as specified by RFC 2616:
  13 +
  14 + http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.1"""
  15 +
  16 + def assert_accept_mimetype(self, mimetype, expect=None):
  17 + """Assert that a request with given mimetype in the accept header,
  18 + gives a response with the appropriate content-type."""
  19 + if expect is None:
  20 + expect = mimetype
  21 +
  22 + resp = self.client.get(reverse(views.RootResource), HTTP_ACCEPT=mimetype)
  23 +
  24 + self.assertEquals(resp['content-type'], expect)
  25 +
  26 +
  27 + def test_accept_json(self):
  28 + """Ensure server responds with Content-Type of JSON when requested."""
  29 + self.assert_accept_mimetype('application/json')
  30 +
  31 + def test_accept_xml(self):
  32 + """Ensure server responds with Content-Type of XML when requested."""
  33 + self.assert_accept_mimetype('application/xml')
  34 +
  35 + def test_accept_json_when_prefered_to_xml(self):
  36 + """Ensure server responds with Content-Type of JSON when it is the client's prefered choice."""
  37 + self.assert_accept_mimetype('application/json;q=0.9, application/xml;q=0.1', expect='application/json')
  38 +
  39 + def test_accept_xml_when_prefered_to_json(self):
  40 + """Ensure server responds with Content-Type of XML when it is the client's prefered choice."""
  41 + self.assert_accept_mimetype('application/json;q=0.1, application/xml;q=0.9', expect='application/xml')
  42 +
  43 + def test_default_json_prefered(self):
  44 + """Ensure server responds with JSON in preference to XML."""
  45 + self.assert_accept_mimetype('application/json,application/xml', expect='application/json')
  46 +
  47 + def test_accept_generic_subtype_format(self):
  48 + """Ensure server responds with an appropriate type, when the subtype is left generic."""
  49 + self.assert_accept_mimetype('text/*', expect='text/html')
  50 +
  51 + def test_accept_generic_type_format(self):
  52 + """Ensure server responds with an appropriate type, when the type and subtype are left generic."""
  53 + self.assert_accept_mimetype('*/*', expect='application/json')
  54 +
  55 + def test_invalid_accept_header_returns_406(self):
  56 + """Ensure server returns a 406 (not acceptable) response if we set the Accept header to junk."""
  57 + resp = self.client.get(reverse(views.RootResource), HTTP_ACCEPT='invalid/invalid')
  58 + self.assertNotEquals(resp['content-type'], 'invalid/invalid')
  59 + self.assertEquals(resp.status_code, 406)
  60 +
  61 + def test_prefer_specific_over_generic(self): # This test is broken right now
  62 + """More specific accept types have precedence over less specific types."""
  63 + self.assert_accept_mimetype('application/xml, */*', expect='application/xml')
  64 + self.assert_accept_mimetype('*/*, application/xml', expect='application/xml')
  65 +
  66 +
  67 +class AllowedMethodsTests(TestCase):
  68 + """Basic tests to check that only allowed operations may be performed on a Resource"""
  69 +
  70 + def test_reading_a_read_only_resource_is_allowed(self):
  71 + """GET requests on a read only resource should default to a 200 (OK) response"""
  72 + resp = self.client.get(reverse(views.RootResource))
  73 + self.assertEquals(resp.status_code, 200)
  74 +
  75 + def test_writing_to_read_only_resource_is_not_allowed(self):
  76 + """PUT requests on a read only resource should default to a 405 (method not allowed) response"""
  77 + resp = self.client.put(reverse(views.RootResource), {})
  78 + self.assertEquals(resp.status_code, 405)
  79 +#
  80 +# def test_reading_write_only_not_allowed(self):
  81 +# resp = self.client.get(reverse(views.WriteOnlyResource))
  82 +# self.assertEquals(resp.status_code, 405)
  83 +#
  84 +# def test_writing_write_only_allowed(self):
  85 +# resp = self.client.put(reverse(views.WriteOnlyResource), {})
  86 +# self.assertEquals(resp.status_code, 200)
  87 +#
  88 +#
  89 +#class EncodeDecodeTests(TestCase):
  90 +# def setUp(self):
  91 +# super(self.__class__, self).setUp()
  92 +# self.input = {'a': 1, 'b': 'example'}
  93 +#
  94 +# def test_encode_form_decode_json(self):
  95 +# content = self.input
  96 +# resp = self.client.put(reverse(views.WriteOnlyResource), content)
  97 +# output = json.loads(resp.content)
  98 +# self.assertEquals(self.input, output)
  99 +#
  100 +# def test_encode_json_decode_json(self):
  101 +# content = json.dumps(self.input)
  102 +# resp = self.client.put(reverse(views.WriteOnlyResource), content, 'application/json')
  103 +# output = json.loads(resp.content)
  104 +# self.assertEquals(self.input, output)
  105 +#
  106 +# #def test_encode_xml_decode_json(self):
  107 +# # content = dict2xml(self.input)
  108 +# # resp = self.client.put(reverse(views.WriteOnlyResource), content, 'application/json', HTTP_ACCEPT='application/json')
  109 +# # output = json.loads(resp.content)
  110 +# # self.assertEquals(self.input, output)
  111 +#
  112 +# #def test_encode_form_decode_xml(self):
  113 +# # content = self.input
  114 +# # resp = self.client.put(reverse(views.WriteOnlyResource), content, HTTP_ACCEPT='application/xml')
  115 +# # output = xml2dict(resp.content)
  116 +# # self.assertEquals(self.input, output)
  117 +#
  118 +# #def test_encode_json_decode_xml(self):
  119 +# # content = json.dumps(self.input)
  120 +# # resp = self.client.put(reverse(views.WriteOnlyResource), content, 'application/json', HTTP_ACCEPT='application/xml')
  121 +# # output = xml2dict(resp.content)
  122 +# # self.assertEquals(self.input, output)
  123 +#
  124 +# #def test_encode_xml_decode_xml(self):
  125 +# # content = dict2xml(self.input)
  126 +# # resp = self.client.put(reverse(views.WriteOnlyResource), content, 'application/json', HTTP_ACCEPT='application/xml')
  127 +# # output = xml2dict(resp.content)
  128 +# # self.assertEquals(self.input, output)
  129 +#
  130 +#class ModelTests(TestCase):
  131 +# def test_create_container(self):
  132 +# content = json.dumps({'name': 'example'})
  133 +# resp = self.client.post(reverse(views.ContainerFactory), content, 'application/json')
  134 +# output = json.loads(resp.content)
  135 +# self.assertEquals(resp.status_code, 201)
  136 +# self.assertEquals(output['name'], 'example')
  137 +# self.assertEquals(set(output.keys()), set(('absolute_uri', 'name', 'key')))
  138 +#
  139 +#class CreatedModelTests(TestCase):
  140 +# def setUp(self):
  141 +# content = json.dumps({'name': 'example'})
  142 +# resp = self.client.post(reverse(views.ContainerFactory), content, 'application/json', HTTP_ACCEPT='application/json')
  143 +# self.container = json.loads(resp.content)
  144 +#
  145 +# def test_read_container(self):
  146 +# resp = self.client.get(self.container["absolute_uri"])
  147 +# self.assertEquals(resp.status_code, 200)
  148 +# container = json.loads(resp.content)
  149 +# self.assertEquals(container, self.container)
  150 +#
  151 +# def test_delete_container(self):
  152 +# resp = self.client.delete(self.container["absolute_uri"])
  153 +# self.assertEquals(resp.status_code, 204)
  154 +# self.assertEquals(resp.content, '')
  155 +#
  156 +# def test_update_container(self):
  157 +# self.container['name'] = 'new'
  158 +# content = json.dumps(self.container)
  159 +# resp = self.client.put(self.container["absolute_uri"], content, 'application/json')
  160 +# self.assertEquals(resp.status_code, 200)
  161 +# container = json.loads(resp.content)
  162 +# self.assertEquals(container, self.container)
  163 +
11 examples/blogpost/urls.py
... ... @@ -0,0 +1,11 @@
  1 +from django.conf.urls.defaults import patterns
  2 +
  3 +urlpatterns = patterns('blogpost.views',
  4 + (r'^$', 'RootResource'),
  5 + (r'^blog-posts/$', 'BlogPostList'),
  6 + (r'^blog-post/$', 'BlogPostCreator'),
  7 + (r'^blog-post/(?P<key>[^/]+)/$', 'BlogPostInstance'),
  8 + (r'^blog-post/(?P<blogpost_id>[^/]+)/comments/$', 'CommentList'),
  9 + (r'^blog-post/(?P<blogpost_id>[^/]+)/comment/$', 'CommentCreator'),
  10 + (r'^blog-post/(?P<blogpost>[^/]+)/comments/(?P<id>[^/]+)/$', 'CommentInstance'),
  11 +)
63 examples/blogpost/views.py
... ... @@ -0,0 +1,63 @@
  1 +from flywheel.response import Response, status
  2 +from flywheel.resource import Resource
  3 +from flywheel.modelresource import ModelResource, QueryModelResource
  4 +from blogpost.models import BlogPost, Comment
  5 +
  6 +##### Root Resource #####
  7 +
  8 +class RootResource(Resource):
  9 + """This is the top level resource for the API.
  10 + All the sub-resources are discoverable from here."""
  11 + allowed_methods = ('GET',)
  12 +
  13 + def get(self, request, *args, **kwargs):
  14 + return Response(status.HTTP_200_OK,
  15 + {'blog-posts': self.reverse(BlogPostList),
  16 + 'blog-post': self.reverse(BlogPostCreator)})
  17 +
  18 +
  19 +##### Blog Post Resources #####
  20 +
  21 +BLOG_POST_FIELDS = ('created', 'title', 'slug', 'content', 'absolute_url', 'comment_url', 'comments_url')
  22 +
  23 +class BlogPostList(QueryModelResource):
  24 + """A resource which lists all existing blog posts."""
  25 + allowed_methods = ('GET', )
  26 + model = BlogPost
  27 + fields = BLOG_POST_FIELDS
  28 +
  29 +class BlogPostCreator(ModelResource):
  30 + """A resource with which blog posts may be created."""
  31 + allowed_methods = ('POST',)
  32 + model = BlogPost
  33 + fields = BLOG_POST_FIELDS
  34 +
  35 +class BlogPostInstance(ModelResource):
  36 + """A resource which represents a single blog post."""
  37 + allowed_methods = ('GET', 'PUT', 'DELETE')
  38 + model = BlogPost
  39 + fields = BLOG_POST_FIELDS
  40 +
  41 +
  42 +##### Comment Resources #####
  43 +
  44 +COMMENT_FIELDS = ('username', 'comment', 'created', 'rating', 'absolute_url', 'blogpost_url')
  45 +
  46 +class CommentList(QueryModelResource):
  47 + """A resource which lists all existing comments for a given blog post."""
  48 + allowed_methods = ('GET', )
  49 + model = Comment
  50 + fields = COMMENT_FIELDS
  51 +
  52 +class CommentCreator(ModelResource):
  53 + """A resource with which blog comments may be created for a given blog post."""
  54 + allowed_methods = ('POST',)
  55 + model = Comment
  56 + fields = COMMENT_FIELDS
  57 +
  58 +class CommentInstance(ModelResource):
  59 + """A resource which represents a single comment."""
  60 + allowed_methods = ('GET', 'PUT', 'DELETE')
  61 + model = Comment
  62 + fields = COMMENT_FIELDS
  63 +
20 examples/initial_data.json
... ... @@ -0,0 +1,20 @@
  1 +[
  2 + {
  3 + "pk": 1,
  4 + "model": "auth.user",
  5 + "fields": {
  6 + "username": "admin",
  7 + "first_name": "",
  8 + "last_name": "",
  9 + "is_active": true,
  10 + "is_superuser": true,
  11 + "is_staff": true,
  12 + "last_login": "2010-01-01 00:00:00",
  13 + "groups": [],
  14 + "user_permissions": [],
  15 + "password": "sha1$6cbce$e4e808893d586a3301ac3c14da6c84855999f1d8",
  16 + "email": "test@example.com",
  17 + "date_joined": "2010-01-01 00:00:00"
  18 + }
  19 + }
  20 +]
11 examples/manage.py
... ... @@ -0,0 +1,11 @@
  1 +#!/usr/bin/env python
  2 +from django.core.management import execute_manager
  3 +try:
  4 + import settings # Assumed to be in the same directory.
  5 +except ImportError:
  6 + import sys
  7 + sys.stderr.write("Error: Can't find the file 'settings.py' in the directory containing %r. It appears you've customized things.\nYou'll have to run django-admin.py, passing it your settings module.\n(If the file settings.py does indeed exist, it's causing an ImportError somehow.)\n" % __file__)
  8 + sys.exit(1)
  9 +
  10 +if __name__ == "__main__":
  11 + execute_manager(settings)
0  examples/objectstore/__init__.py
No changes.
3  examples/objectstore/models.py
... ... @@ -0,0 +1,3 @@
  1 +from django.db import models
  2 +
  3 +# Create your models here.
23 examples/objectstore/tests.py
... ... @@ -0,0 +1,23 @@
  1 +"""
  2 +This file demonstrates two different styles of tests (one doctest and one
  3 +unittest). These will both pass when you run "manage.py test".
  4 +
  5 +Replace these with more appropriate tests for your application.
  6 +"""
  7 +
  8 +from django.test import TestCase
  9 +
  10 +class SimpleTest(TestCase):
  11 + def test_basic_addition(self):
  12 + """
  13 + Tests that 1 + 1 always equals 2.
  14 + """
  15 + self.failUnlessEqual(1 + 1, 2)
  16 +
  17 +__test__ = {"doctest": """
  18 +Another way to test that 1 + 1 is equal to 2.
  19 +
  20 +>>> 1 + 1 == 2
  21 +True
  22 +"""}
  23 +
6 examples/objectstore/urls.py
... ... @@ -0,0 +1,6 @@
  1 +from django.conf.urls.defaults import patterns
  2 +
  3 +urlpatterns = patterns('objectstore.views',
  4 + (r'^$', 'ObjectStoreRoot'),
  5 + (r'^(?P<key>[A-Za-z0-9_-]{1,64})/$', 'StoredObject'),
  6 +)
54 examples/objectstore/views.py
... ... @@ -0,0 +1,54 @@
  1 +from django.conf import settings
  2 +
  3 +from flywheel.resource import Resource
  4 +from flywheel.response import Response, status
  5 +
  6 +import pickle
  7 +import os
  8 +import uuid
  9 +
  10 +OBJECT_STORE_DIR = os.path.join(settings.MEDIA_ROOT, 'objectstore')
  11 +
  12 +
  13 +class ObjectStoreRoot(Resource):
  14 + """Root of the Object Store API.
  15 + Allows the client to get a complete list of all the stored objects, or to create a new stored object."""
  16 + allowed_methods = ('GET', 'POST')
  17 +
  18 + def get(self, request):
  19 + """Return a list of all the stored object URLs."""
  20 + keys = sorted(os.listdir(OBJECT_STORE_DIR))
  21 + return [self.reverse(StoredObject, key=key) for key in keys]
  22 +
  23 + def post(self, request, content):
  24 + """Create a new stored object, with a unique key."""
  25 + key = str(uuid.uuid1())
  26 + pathname = os.path.join(OBJECT_STORE_DIR, key)
  27 + pickle.dump(content, open(pathname, 'wb'))
  28 + return Response(status.HTTP_201_CREATED, content, {'Location': self.reverse(StoredObject, key=key)})
  29 +
  30 +
  31 +class StoredObject(Resource):
  32 + """Represents a stored object.
  33 + The object may be any picklable content."""
  34 + allowed_methods = ('GET', 'PUT', 'DELETE')
  35 +
  36 + def get(self, request, key):
  37 + """Return a stored object, by unpickling the contents of a locally stored file."""
  38 + pathname = os.path.join(OBJECT_STORE_DIR, key)
  39 + if not os.path.exists(pathname):
  40 + return Response(status.HTTP_404_NOT_FOUND)
  41 + return pickle.load(open(pathname, 'rb'))
  42 +
  43 + def put(self, request, content, key):
  44 + """Update/create a stored object, by pickling the request content to a locally stored file."""
  45 + pathname = os.path.join(OBJECT_STORE_DIR, key)
  46 + pickle.dump(content, open(pathname, 'wb'))
  47 + return content
  48 +
  49 + def delete(self, request, key):
  50 + """Delete a stored object, by removing it's pickled file."""
  51 + pathname = os.path.join(OBJECT_STORE_DIR, key)
  52 + if not os.path.exists(pathname):
  53 + return Response(status.HTTP_404_NOT_FOUND)
  54 + os.remove(pathname)
96 examples/settings.py
... ... @@ -0,0 +1,96 @@
  1 +# Django settings for src project.
  2 +
  3 +DEBUG = True
  4 +TEMPLATE_DEBUG = DEBUG
  5 +
  6 +ADMINS = (
  7 + # ('Your Name', 'your_email@domain.com'),
  8 +)
  9 +
  10 +MANAGERS = ADMINS
  11 +
  12 +DATABASES = {
  13 + 'default': {
  14 + 'ENGINE': 'django.db.backends.sqlite3', # Add 'postgresql_psycopg2', 'postgresql', 'mysql', 'sqlite3' or 'oracle'.
  15 + 'NAME': 'sqlite3.db', # Or path to database file if using sqlite3.
  16 + 'USER': '', # Not used with sqlite3.
  17 + 'PASSWORD': '', # Not used with sqlite3.
  18 + 'HOST': '', # Set to empty string for localhost. Not used with sqlite3.
  19 + 'PORT': '', # Set to empty string for default. Not used with sqlite3.
  20 + }
  21 +}
  22 +
  23 +# Local time zone for this installation. Choices can be found here:
  24 +# http://en.wikipedia.org/wiki/List_of_tz_zones_by_name
  25 +# although not all choices may be available on all operating systems.
  26 +# On Unix systems, a value of None will cause Django to use the same
  27 +# timezone as the operating system.
  28 +# If running in a Windows environment this must be set to the same as your
  29 +# system time zone.
  30 +TIME_ZONE = 'Europe/London'
  31 +
  32 +# Language code for this installation. All choices can be found here:
  33 +# http://www.i18nguy.com/unicode/language-identifiers.html
  34 +LANGUAGE_CODE = 'en-uk'
  35 +
  36 +SITE_ID = 1
  37 +
  38 +# If you set this to False, Django will make some optimizations so as not
  39 +# to load the internationalization machinery.
  40 +USE_I18N = True
  41 +
  42 +# If you set this to False, Django will not format dates, numbers and
  43 +# calendars according to the current locale
  44 +USE_L10N = True
  45 +
  46 +# Absolute filesystem path to the directory that will hold user-uploaded files.
  47 +# Example: "/home/media/media.lawrence.com/"
  48 +MEDIA_ROOT = '/Users/tomchristie/'
  49 +
  50 +# URL that handles the media served from MEDIA_ROOT. Make sure to use a
  51 +# trailing slash if there is a path component (optional in other cases).
  52 +# Examples: "http://media.lawrence.com", "http://example.com/media/"
  53 +MEDIA_URL = ''
  54 +
  55 +# URL prefix for admin media -- CSS, JavaScript and images. Make sure to use a
  56 +# trailing slash.
  57 +# Examples: "http://foo.com/media/", "/media/".
  58 +ADMIN_MEDIA_PREFIX = '/media/'
  59 +
  60 +# Make this unique, and don't share it with anybody.
  61 +SECRET_KEY = 't&9mru2_k$t8e2-9uq-wu2a1)9v*us&j3i#lsqkt(lbx*vh1cu'
  62 +
  63 +# List of callables that know how to import templates from various sources.
  64 +TEMPLATE_LOADERS = (
  65 + 'django.template.loaders.filesystem.Loader',
  66 + 'django.template.loaders.app_directories.Loader',
  67 +# 'django.template.loaders.eggs.Loader',
  68 +)
  69 +
  70 +MIDDLEWARE_CLASSES = (
  71 + 'django.middleware.common.CommonMiddleware',
  72 + 'django.contrib.sessions.middleware.SessionMiddleware',
  73 + 'django.middleware.csrf.CsrfViewMiddleware',
  74 + 'django.contrib.auth.middleware.AuthenticationMiddleware',
  75 + 'django.contrib.messages.middleware.MessageMiddleware',
  76 +)
  77 +
  78 +ROOT_URLCONF = 'urls'
  79 +
  80 +TEMPLATE_DIRS = (
  81 + # Put strings here, like "/home/html/django_templates" or "C:/www/django/templates".
  82 + # Always use forward slashes, even on Windows.
  83 + # Don't forget to use absolute paths, not relative paths.
  84 +)
  85 +
  86 +INSTALLED_APPS = (
  87 + 'django.contrib.auth',
  88 + 'django.contrib.contenttypes',
  89 + 'django.contrib.sessions',
  90 + 'django.contrib.sites',
  91 + 'django.contrib.messages',
  92 + 'django.contrib.admin',
  93 + 'flywheel',
  94 + 'blogpost',
  95 + 'objectstore'
  96 +)
11 examples/urls.py
... ... @@ -0,0 +1,11 @@
  1 +from django.conf.urls.defaults import patterns, include
  2 +from django.contrib import admin
  3 +
  4 +admin.autodiscover()
  5 +
  6 +urlpatterns = patterns('',
  7 + (r'^blog-post-example/', include('blogpost.urls')),
  8 + (r'^object-store-example/', include('objectstore.urls')),
  9 + (r'^admin/doc/', include('django.contrib.admindocs.urls')),
  10 + (r'^admin/', include(admin.site.urls)),
  11 +)
0  flywheel/__init__.py
No changes.
118 flywheel/emitters.py
... ... @@ -0,0 +1,118 @@
  1 +from django.template import RequestContext, loader
  2 +
  3 +from flywheel.response import NoContent
  4 +
  5 +from utils import dict2xml
  6 +try:
  7 + import json
  8 +except ImportError:
  9 + import simplejson as json
  10 +
  11 +
  12 +
  13 +class BaseEmitter(object):
  14 + media_type = None
  15 +
  16 + def __init__(self, resource):
  17 + self.resource = resource
  18 +
  19 + def emit(self, output=NoContent, verbose=False):
  20 + raise Exception('emit() function on a subclass of BaseEmitter must be implemented')
  21 +
  22 +
  23 +from django import forms
  24 +class JSONForm(forms.Form):
  25 + _contenttype = forms.CharField(max_length=256, initial='application/json', label='Content Type')
  26 + _content = forms.CharField(label='Content', widget=forms.Textarea)
  27 +
  28 +class DocumentingTemplateEmitter(BaseEmitter):
  29 + """Emitter used to self-document the API"""
  30 + template = None
  31 +
  32 + def emit(self, output=NoContent):
  33 + resource = self.resource
  34 +
  35 + # Find the first valid emitter and emit the content. (Don't another documenting emitter.)
  36 + emitters = [emitter for emitter in resource.emitters if not isinstance(emitter, DocumentingTemplateEmitter)]
  37 + if not emitters:
  38 + content = 'No emitters were found'
  39 + else:
  40 + content = emitters[0](resource).emit(output, verbose=True)
  41 +
  42 + # Get the form instance if we have one bound to the input
  43 + form_instance = resource.form_instance
  44 +
  45 + # Otherwise if this isn't an error response
  46 + # then attempt to get a form bound to the response object
  47 + if not form_instance and not resource.response.is_error and resource.response.has_content_body:
  48 + try:
  49 + form_instance = resource.get_form(resource.response.raw_content)
  50 + except:
  51 + pass
  52 +
  53 + # If we still don't have a form instance then try to get an unbound form
  54 + if not form_instance:
  55 + try:
  56 + form_instance = self.resource.get_form()
  57 + except:
  58 + pass
  59 +
  60 + if not form_instance:
  61 + form_instance = JSONForm()
  62 +
  63 + template = loader.get_template(self.template)
  64 + context = RequestContext(self.resource.request, {
  65 + 'content': content,
  66 + 'resource': self.resource,
  67 + 'request': self.resource.request,
  68 + 'response': self.resource.response,
  69 + 'form': form_instance
  70 + })
  71 +
  72 + ret = template.render(context)
  73 +
  74 + # Munge DELETE Response code to allow us to return content
  75 + # (Do this *after* we've rendered the template so that we include the normal deletion response code in the output)
  76 + if self.resource.response.status == 204:
  77 + self.resource.response.status = 200
  78 +
  79 + return ret
  80 +
  81 +
  82 +class JSONEmitter(BaseEmitter):
  83 + media_type = 'application/json'
  84 +
  85 + def emit(self, output=NoContent, verbose=False):
  86 + if output is NoContent:
  87 + return ''
  88 + if verbose:
  89 + return json.dumps(output, indent=4, sort_keys=True)
  90 + return json.dumps(output)
  91 +
  92 +
  93 +class XMLEmitter(BaseEmitter):
  94 + media_type = 'application/xml'
  95 +
  96 + def emit(self, output=NoContent, verbose=False):
  97 + if output is NoContent:
  98 + return ''
  99 + return dict2xml(output)
  100 +
  101 +
  102 +class DocumentingHTMLEmitter(DocumentingTemplateEmitter):
  103 + media_type = 'text/html'
  104 + uses_forms = True
  105 + template = 'emitter.html'
  106 +
  107 +
  108 +class DocumentingXHTMLEmitter(DocumentingTemplateEmitter):
  109 + media_type = 'application/xhtml+xml'
  110 + uses_forms = True
  111 + template = 'emitter.html'
  112 +
  113 +
  114 +class DocumentingPlainTextEmitter(DocumentingTemplateEmitter):
  115 + media_type = 'text/plain'
  116 + template = 'emitter.txt'
  117 +
  118 +
393 flywheel/modelresource.py
... ... @@ -0,0 +1,393 @@
  1 +"""TODO: docs
  2 +"""
  3 +from django.forms import ModelForm
  4 +from django.db.models.query import QuerySet
  5 +from django.db.models import Model
  6 +
  7 +from flywheel.response import status, Response, ResponseException
  8 +from flywheel.resource import Resource
  9 +
  10 +import decimal
  11 +import inspect
  12 +import re
  13 +
  14 +
  15 +class ModelResource(Resource):
  16 + """A specialized type of Resource, for resources that map directly to a Django Model.
  17 + Useful things this provides:
  18 +
  19 + 0. Default input validation based on ModelForms.
  20 + 1. Nice serialization of returned Models and QuerySets.
  21 + 2. A default set of create/read/update/delete operations."""
  22 +
  23 + # The model attribute refers to the Django Model which this Resource maps to.
  24 + # (The Model's class, rather than an instance of the Model)
  25 + model = None
  26 +
  27 + # By default the set of returned fields will be the set of:
  28 + #
  29 + # 0. All the fields on the model, excluding 'id'.
  30 + # 1. All the properties on the model.
  31 + # 2. The absolute_url of the model, if a get_absolute_url method exists for the model.
  32 + #
  33 + # If you wish to override this behaviour,
  34 + # you should explicitly set the fields attribute on your class.
  35 + fields = None
  36 +
  37 + # By default the form used with be a ModelForm for self.model
  38 + # If you wish to override this behaviour or provide a sub-classed ModelForm
  39 + # you should explicitly set the form attribute on your class.
  40 + form = None
  41 +
  42 + # By default the set of input fields will be the same as the set of output fields
  43 + # If you wish to override this behaviour you should explicitly set the
  44 + # form_fields attribute on your class.
  45 + form_fields = None
  46 +
  47 +
  48 + def get_form(self, content=None):
  49 + """Return a form that may be used in validation and/or rendering an html emitter"""
  50 + if self.form:
  51 + return super(self.__class__, self).get_form(content)
  52 +
  53 + elif self.model:
  54 +
  55 + class NewModelForm(ModelForm):
  56 + class Meta:
  57 + model = self.model
  58 + fields = self.form_fields if self.form_fields else None
  59 +
  60 + if content and isinstance(content, Model):
  61 + return NewModelForm(instance=content)
  62 + elif content:
  63 + return NewModelForm(content)
  64 +
  65 + return NewModelForm()
  66 +
  67 + return None
  68 +
  69 +
  70 + def cleanup_request(self, data, form_instance):
  71 + """Override cleanup_request to drop read-only fields from the input prior to validation.
  72 + This ensures that we don't error out with 'non-existent field' when these fields are supplied,
  73 + and allows for a pragmatic approach to resources which include read-only elements.
  74 +
  75 + I would actually like to be strict and verify the value of correctness of the values in these fields,
  76 + although that gets tricky as it involves validating at the point that we get the model instance.
  77 +
  78 + See here for another example of this approach:
  79 + http://fedoraproject.org/wiki/Cloud_APIs_REST_Style_Guide
  80 + https://www.redhat.com/archives/rest-practices/2010-April/thread.html#00041"""
  81 + read_only_fields = set(self.fields) - set(self.form_instance.fields)
  82 + input_fields = set(data.keys())
  83 +
  84 + clean_data = {}
  85 + for key in input_fields - read_only_fields:
  86 + clean_data[key] = data[key]
  87 +
  88 + return super(ModelResource, self).cleanup_request(clean_data, form_instance)
  89 +
  90 +
  91 + def cleanup_response(self, data):
  92 + """A munging of Piston's pre-serialization. Returns a dict"""
  93 +
  94 + def _any(thing, fields=()):
  95 + """
  96 + Dispatch, all types are routed through here.
  97 + """
  98 + ret = None
  99 +
  100 + if isinstance(thing, QuerySet):
  101 + ret = _qs(thing, fields=fields)
  102 + elif isinstance(thing, (tuple, list)):
  103 + ret = _list(thing)
  104 + elif isinstance(thing, dict):
  105 + ret = _dict(thing)
  106 + elif isinstance(thing, int):
  107 + ret = thing
  108 + elif isinstance(thing, bool):
  109 + ret = thing
  110 + elif isinstance(thing, type(None)):
  111 + ret = thing
  112 + elif isinstance(thing, decimal.Decimal):
  113 + ret = str(thing)
  114 + elif isinstance(thing, Model):
  115 + ret = _model(thing, fields=fields)
  116 + #elif isinstance(thing, HttpResponse): TRC
  117 + # raise HttpStatusCode(thing)
  118 + elif inspect.isfunction(thing):
  119 + if not inspect.getargspec(thing)[0]:
  120 + ret = _any(thing())
  121 + elif hasattr(thing, '__emittable__'):
  122 + f = thing.__emittable__
  123 + if inspect.ismethod(f) and len(inspect.getargspec(f)[0]) == 1:
  124 + ret = _any(f())
  125 + else:
  126 + ret = str(thing) # TRC TODO: Change this back!
  127 +
  128 + return ret
  129 +
  130 + def _fk(data, field):
  131 + """
  132 + Foreign keys.
  133 + """
  134 + return _any(getattr(data, field.name))
  135 +
  136 + def _related(data, fields=()):
  137 + """
  138 + Foreign keys.
  139 + """
  140 + return [ _model(m, fields) for m in data.iterator() ]
  141 +
  142 + def _m2m(data, field, fields=()):
  143 + """
  144 + Many to many (re-route to `_model`.)
  145 + """
  146 + return [ _model(m, fields) for m in getattr(data, field.name).iterator() ]
  147 +
  148 +
  149 + def _method_fields(data, fields):
  150 + if not data:
  151 + return { }
  152 +
  153 + has = dir(data)
  154 + ret = dict()
  155 +
  156 + for field in fields:
  157 + if field in has:
  158 + ret[field] = getattr(data, field)
  159 +
  160 + return ret
  161 +
  162 + def _model(data, fields=()):
  163 + """
  164 + Models. Will respect the `fields` and/or
  165 + `exclude` on the handler (see `typemapper`.)
  166 + """
  167 + ret = { }
  168 + #handler = self.in_typemapper(type(data), self.anonymous) # TRC
  169 + handler = None # TRC
  170 + get_absolute_url = False
  171 +
  172 + if handler or fields:
  173 + v = lambda f: getattr(data, f.attname)
  174 +
  175 + if not fields:
  176 + """
  177 + Fields was not specified, try to find teh correct
  178 + version in the typemapper we were sent.
  179 + """
  180 + mapped = self.in_typemapper(type(data), self.anonymous)
  181 + get_fields = set(mapped.fields)
  182 + exclude_fields = set(mapped.exclude).difference(get_fields)
  183 +
  184 + if not get_fields:
  185 + get_fields = set([ f.attname.replace("_id", "", 1)
  186 + for f in data._meta.fields ])
  187 +
  188 + # sets can be negated.
  189 + for exclude in exclude_fields:
  190 + if isinstance(exclude, basestring):
  191 + get_fields.discard(exclude)
  192 +
  193 + elif isinstance(exclude, re._pattern_type):
  194 + for field in get_fields.copy():
  195 + if exclude.match(field):
  196 + get_fields.discard(field)
  197 +
  198 + get_absolute_url = True
  199 +
  200 + else:
  201 + get_fields = set(fields)
  202 + if 'absolute_url' in get_fields: # MOVED (TRC)
  203 + get_absolute_url = True
  204 +
  205 + met_fields = _method_fields(handler, get_fields) # TRC
  206 +
  207 + for f in data._meta.local_fields:
  208 + if f.serialize and not any([ p in met_fields for p in [ f.attname, f.name ]]):
  209 + if not f.rel:
  210 + if f.attname in get_fields:
  211 + ret[f.attname] = _any(v(f))
  212 + get_fields.remove(f.attname)
  213 + else:
  214 + if f.attname[:-3] in get_fields:
  215 + ret[f.name] = _fk(data, f)
  216 + get_fields.remove(f.name)
  217 +
  218 + for mf in data._meta.many_to_many:
  219 + if mf.serialize and mf.attname not in met_fields:
  220 + if mf.attname in get_fields:
  221 + ret[mf.name] = _m2m(data, mf)
  222 + get_fields.remove(mf.name)
  223 +
  224 + # try to get the remainder of fields
  225 + for maybe_field in get_fields:
  226 +
  227 + if isinstance(maybe_field, (list, tuple)):
  228 + model, fields = maybe_field
  229 + inst = getattr(data, model, None)
  230 +
  231 + if inst:
  232 + if hasattr(inst, 'all'):
  233 + ret[model] = _related(inst, fields)
  234 + elif callable(inst):
  235 + if len(inspect.getargspec(inst)[0]) == 1:
  236 + ret[model] = _any(inst(), fields)
  237 + else:
  238 + ret[model] = _model(inst, fields)
  239 +
  240 + elif maybe_field in met_fields:
  241 + # Overriding normal field which has a "resource method"
  242 + # so you can alter the contents of certain fields without
  243 + # using different names.
  244 + ret[maybe_field] = _any(met_fields[maybe_field](data))
  245 +
  246 + else:
  247 + maybe = getattr(data, maybe_field, None)
  248 + if maybe:
  249 + if callable(maybe):
  250 + if len(inspect.getargspec(maybe)[0]) == 1:
  251 + ret[maybe_field] = _any(maybe())
  252 + else:
  253 + ret[maybe_field] = _any(maybe)
  254 + else:
  255 + pass # TRC
  256 + #handler_f = getattr(handler or self.handler, maybe_field, None)
  257 + #
  258 + #if handler_f:
  259 + # ret[maybe_field] = _any(handler_f(data))
  260 +
  261 + else:
  262 + # Add absolute_url if it exists