Skip to content

Commit

Permalink
Fixed one-to-many data creation.
Browse files Browse the repository at this point in the history
To use this, you need to supply a ``related_name`` attribute. Thanks to shawnlewis, paperino & others for the report and gudmundur for an unused patch!
  • Loading branch information
toastdriven committed Sep 18, 2011
1 parent 3343388 commit 51e0b7e
Show file tree
Hide file tree
Showing 11 changed files with 250 additions and 42 deletions.
61 changes: 61 additions & 0 deletions docs/bundles.rst
@@ -0,0 +1,61 @@
.. ref-bundle:
=======
Bundles
=======


What Are Bundles?
=================

Bundles are a small abstraction that allow Tastypie to pass data between
resources. This allows us not to depend on passing ``request`` to every single
method (especially in places where this would be overkill). It also allows
resources to work with data coming into the application paired together with
an unsaved instance of the object in question. Finally, it aids in keeping
Tastypie more thread-safe.

Think of it as package of user data & an object instance (either of which are
optionally present).


Attributes
==========

All data within a bundle can be optional, especially depending on how it's
being used. If you write custom code using ``Bundle``, make sure appropriate
guards are in place.

``obj``
-------

Either a Python object or ``None``.

Usually a Django model, though it may/may not have been saved already.

``data``
--------

Always a plain Python dictionary of data. If not provided, it will be empty.

``request``
-----------

Either the Django ``request`` that's part of the issued request or an empty
``HttpRequest`` if it wasn't provided.

``related_obj``
---------------

Either another "parent" Python object or ``None``.

Useful when handling one-to-many relations. Used in conjunction with
``related_name``.

``related_name``
----------------

Either a Python string name of an attribute or ``None``.

Useful when handling one-to-many relations. Used in conjunction with
``related_obj``.
24 changes: 22 additions & 2 deletions docs/fields.rst
Expand Up @@ -223,8 +223,28 @@ be included in full.

.. attribute:: RelatedField.related_name

Currently unused, as unlike Django's ORM layer, reverse relations between
``Resource`` classes are not automatically created. Defaults to ``None``.
Used to help automatically populate reverse relations when creating data.
Defaults to ``None``.

In order for this option to work correctly, there must be a field on the
other ``Resource`` with this as an ``attribute/instance_name``. Usually this
just means adding a reflecting ``ToOneField`` pointing back.

Example::

class EntryResource(ModelResource):
authors = fields.ToManyField('path.to.api.resources.AuthorResource', 'author_set', related_name='entry')

class Meta:
queryset = Entry.objects.all()
resource_name = 'entry'

class AuthorResource(ModelResource):
entry = fields.ToOneField(EntryResource, 'entry')

class Meta:
queryset = Author.objects.all()
resource_name = 'author'


Field Types
Expand Down
1 change: 1 addition & 0 deletions docs/index.rst
Expand Up @@ -15,6 +15,7 @@ interfaces.
tools

resources
bundles
api
fields
caching
Expand Down
13 changes: 0 additions & 13 deletions docs/resources.rst
Expand Up @@ -135,19 +135,6 @@ of POST/PUT, the ``hydrate`` cycle additionally takes place and is used to take
the user data & convert it to raw data for storage.


What Are Bundles?
=================

Bundles are a small abstraction that allow Tastypie to pass data between
resources. This allows us not to depend on passing ``request`` to every single
method (especially in places where this would be overkill). It also allows
resources to work with data coming into the application paired together with
an unsaved instance of the object in question.

Think of it as package of user data & an object instance (either of which are
optionally present).


Why Resource URIs?
==================

Expand Down
1 change: 1 addition & 0 deletions docs/toc.rst
Expand Up @@ -12,6 +12,7 @@ Table Of Contents
tools

resources
bundles
api
fields
authentication_authorization
Expand Down
8 changes: 5 additions & 3 deletions tastypie/bundle.py
Expand Up @@ -6,14 +6,16 @@ class Bundle(object):
"""
A small container for instances and converted data for the
``dehydrate/hydrate`` cycle.
Necessary because the ``dehydrate/hydrate`` cycle needs to access data at
different points.
"""
def __init__(self, obj=None, data=None, request=None):
def __init__(self, obj=None, data=None, request=None, related_obj=None, related_name=None):
self.obj = obj
self.data = data or {}
self.request = request or HttpRequest()

self.related_obj = related_obj
self.related_name = related_name

def __repr__(self):
return "<Bundle for obj: '%s' and with data: '%s'>" % (self.obj, self.data)
61 changes: 49 additions & 12 deletions tastypie/fields.py
Expand Up @@ -142,6 +142,12 @@ def hydrate(self, bundle):
return None

if not bundle.data.has_key(self.instance_name):
if getattr(self, 'is_related', False) and not getattr(self, 'is_m2m', False):
# We've got an FK (or alike field) & a possible parent object.
# Check for it.
if bundle.related_obj and bundle.related_name in (self.attribute, self.instance_name):
return bundle.related_obj

if self.blank:
return None
elif self.attribute and getattr(bundle.obj, self.attribute, None):
Expand Down Expand Up @@ -506,7 +512,7 @@ def dehydrate_related(self, bundle, related_resource):
bundle = related_resource.build_bundle(obj=related_resource.instance, request=bundle.request)
return related_resource.full_dehydrate(bundle)

def resource_from_uri(self, fk_resource, uri, request=None):
def resource_from_uri(self, fk_resource, uri, request=None, related_obj=None, related_name=None):
"""
Given a URI is provided, the related resource is attempted to be
loaded based on the identifiers in the URI.
Expand All @@ -518,7 +524,7 @@ def resource_from_uri(self, fk_resource, uri, request=None):
except ObjectDoesNotExist:
raise ApiFieldError("Could not find the provided object via resource URI '%s'." % uri)

def resource_from_data(self, fk_resource, data, request=None):
def resource_from_data(self, fk_resource, data, request=None, related_obj=None, related_name=None):
"""
Given a dictionary-like structure is provided, a fresh related
resource is created using that data.
Expand All @@ -527,6 +533,10 @@ def resource_from_data(self, fk_resource, data, request=None):
data = dict_strip_unicode_keys(data)
fk_bundle = fk_resource.build_bundle(data=data, request=request)

if related_obj:
fk_bundle.related_obj = related_obj
fk_bundle.related_name = related_name

# We need to check to see if updates are allowed on the FK
# resource. If not, we'll just return a populated bundle instead
# of mistakenly updating something that should be read-only.
Expand All @@ -549,15 +559,15 @@ def resource_from_data(self, fk_resource, data, request=None):
except MultipleObjectsReturned:
return fk_resource.full_hydrate(fk_bundle)

def resource_from_pk(self, fk_resource, obj, request=None):
def resource_from_pk(self, fk_resource, obj, request=None, related_obj=None, related_name=None):
"""
Given an object with a ``pk`` attribute, the related resource
is attempted to be loaded via that PK.
"""
bundle = fk_resource.build_bundle(obj=obj, request=request)
return fk_resource.full_dehydrate(bundle)

def build_related_resource(self, value, request=None):
def build_related_resource(self, value, request=None, related_obj=None, related_name=None):
"""
Returns a bundle of data built by the related resource, usually via
``hydrate`` with the data provided.
Expand All @@ -566,16 +576,23 @@ def build_related_resource(self, value, request=None):
or an object with a ``pk``.
"""
self.fk_resource = self.to_class()
kwargs = {
'request': request,
'related_obj': related_obj,
'related_name': related_name,
}

if isinstance(value, basestring):
# We got a URI. Load the object and assign it.
return self.resource_from_uri(self.fk_resource, value, request=request)
return self.resource_from_uri(self.fk_resource, value, **kwargs)
elif hasattr(value, 'items'):
# We've got a data dictionary.
return self.resource_from_data(self.fk_resource, value, request=request)
# Since this leads to creation, this is the only one of these
# methods that might care about "parent" data.
return self.resource_from_data(self.fk_resource, value, **kwargs)
elif hasattr(value, 'pk'):
# We've got an object with a primary key.
return self.resource_from_pk(self.fk_resource, value, request=request)
return self.resource_from_pk(self.fk_resource, value, **kwargs)
else:
raise ApiFieldError("The '%s' field has was given data that was not a URI, not a dictionary-alike and does not have a 'pk' attribute: %s." % (self.instance_name, value))

Expand All @@ -588,8 +605,14 @@ class ToOneField(RelatedField):
"""
help_text = 'A single related resource. Can be either a URI or set of nested resource data.'

def __init__(self, to, attribute, related_name=None, default=NOT_PROVIDED, null=False, blank=False, readonly=False, full=False, unique=False, help_text=None):
super(ToOneField, self).__init__(to, attribute, related_name=related_name, default=default, null=null, blank=blank, readonly=readonly, full=full, unique=unique, help_text=help_text)
def __init__(self, to, attribute, related_name=None, default=NOT_PROVIDED,
null=False, blank=False, readonly=False, full=False,
unique=False, help_text=None):
super(ToOneField, self).__init__(
to, attribute, related_name=related_name, default=default,
null=null, blank=blank, readonly=readonly, full=full,
unique=unique, help_text=help_text
)
self.fk_resource = None

def dehydrate(self, bundle):
Expand Down Expand Up @@ -643,8 +666,14 @@ class ToManyField(RelatedField):
is_m2m = True
help_text = 'Many related resources. Can be either a list of URIs or list of individually nested resource data.'

def __init__(self, to, attribute, related_name=None, default=NOT_PROVIDED, null=False, blank=False, readonly=False, full=False, unique=False, help_text=None):
super(ToManyField, self).__init__(to, attribute, related_name=related_name, default=default, null=null, blank=blank, readonly=readonly, full=full, unique=unique, help_text=help_text)
def __init__(self, to, attribute, related_name=None, default=NOT_PROVIDED,
null=False, blank=False, readonly=False, full=False,
unique=False, help_text=None):
super(ToManyField, self).__init__(
to, attribute, related_name=related_name, default=default,
null=null, blank=blank, readonly=readonly, full=full,
unique=unique, help_text=help_text
)
self.m2m_bundles = []

def dehydrate(self, bundle):
Expand Down Expand Up @@ -701,7 +730,15 @@ def hydrate_m2m(self, bundle):
if value is None:
continue

m2m_hydrated.append(self.build_related_resource(value, request=bundle.request))
kwargs = {
'request': bundle.request,
}

if self.related_name:
kwargs['related_obj'] = bundle.obj
kwargs['related_name'] = self.related_name

m2m_hydrated.append(self.build_related_resource(value, **kwargs))

return m2m_hydrated

Expand Down
47 changes: 47 additions & 0 deletions tests/core/tests/fields.py
Expand Up @@ -757,6 +757,15 @@ def test_hydrate(self):
field_12.instance_name = 'author'
self.assertEqual(field_12.hydrate(bundle), None)

# A related object.
field_13 = ToOneField(UserResource, 'author')
field_13.instance_name = 'fk'
bundle.related_obj = User.objects.get(pk=1)
bundle.related_name = 'author'
fk_bundle = field_13.hydrate(bundle)
self.assertEqual(fk_bundle.obj.username, u'johndoe')
self.assertEqual(fk_bundle.obj.email, u'john@doe.com')

def test_resource_from_uri(self):
ur = UserResource()
field_1 = ToOneField(UserResource, 'author')
Expand All @@ -766,6 +775,10 @@ def test_resource_from_uri(self):
self.assertEqual(fk_bundle.obj.username, u'johndoe')
self.assertEqual(fk_bundle.obj.email, u'john@doe.com')

fk_bundle = field_1.resource_from_uri(ur, '/api/v1/users/1/', related_obj='Foo', related_name='Bar')
self.assertEqual(fk_bundle.related_obj, None)
self.assertEqual(fk_bundle.related_name, None)

def test_resource_from_data(self):
ur = UserResource()
field_1 = ToOneField(UserResource, 'author')
Expand All @@ -779,6 +792,14 @@ def test_resource_from_data(self):
self.assertEqual(fk_bundle.obj.username, u'mistersmith')
self.assertEqual(fk_bundle.obj.email, u'smith@example.com')

fk_bundle = field_1.resource_from_data(ur, {
'username': u'mistersmith',
'email': u'smith@example.com',
'password': u'foobar',
}, related_obj='Foo', related_name='Bar')
self.assertEqual(fk_bundle.related_obj, 'Foo')
self.assertEqual(fk_bundle.related_name, 'Bar')

def test_resource_from_pk(self):
user = User.objects.get(pk=1)
ur = UserResource()
Expand All @@ -789,6 +810,10 @@ def test_resource_from_pk(self):
self.assertEqual(fk_bundle.obj.username, u'johndoe')
self.assertEqual(fk_bundle.obj.email, u'john@doe.com')

fk_bundle = field_1.resource_from_pk(ur, user, related_obj='Foo', related_name='Bar')
self.assertEqual(fk_bundle.related_obj, None)
self.assertEqual(fk_bundle.related_name, None)


class SubjectResource(ModelResource):
class Meta:
Expand All @@ -799,6 +824,15 @@ def get_resource_uri(self, bundle):
return '/api/v1/subjects/%s/' % bundle.obj.id


class MediaBitResource(ModelResource):
class Meta:
resource_name = 'mediabits'
queryset = MediaBit.objects.all()

def get_resource_uri(self, bundle):
return '/api/v1/mediabits/%s/' % bundle.obj.id


class ToManyFieldTestCase(TestCase):
fixtures = ['note_testdata.json']
urls = 'core.tests.field_urls'
Expand Down Expand Up @@ -1035,3 +1069,16 @@ def test_hydrate(self):
field_9 = ToManyField(SubjectResource, 'subjects', readonly=True)
field_9.instance_name = 'm2m'
self.assertEqual(field_9.hydrate(bundle_6), None)

# A related object.
field_10 = ToManyField(MediaBitResource, 'media_bits', related_name='note')
field_10.instance_name = 'mbs'
note_1 = Note.objects.get(pk=1)
bundle_10 = Bundle(obj=note_1, data={'mbs': [
{
'title': 'Foo!',
},
]})
media_bundle_list = field_10.hydrate_m2m(bundle_10)
self.assertEqual(len(media_bundle_list), 1)
self.assertEqual(media_bundle_list[0].obj.title, u'Foo!')

0 comments on commit 51e0b7e

Please sign in to comment.