Creating related resources with Tastypie
Asked Answered
W

2

6

I would like tastypie to create a UserProfileResource as a result of me POSTing to a UserResource.

models.py:

class UserProfile(models.Model):
    home_address = models.TextField()
    user = models.ForeignKey(User, unique=True)

resources.py

class UserProfileResource(ModelResource):
    home_address = fields.CharField(attribute='home_address')

    class Meta:
        queryset = UserProfile.objects.all()
        resource_name = 'profile'
        excludes = ['id']
        include_resource_uri = False


class UserResource(ModelResource):
    profile = fields.ToOneField(UserProfileResource, 'profile', full=True)
    class Meta:
        queryset = User.objects.all()
        resource_name = 'user'
        allowed_methods = ['get', 'post', 'delete', 'put']
        fields = ['username']
        filtering = {
                'username': ALL,
                }

curl command:

curl -v -H "Content-Type: application/json" -X POST --data '{"username":"me", "password":"blahblah", "profile":{"home_address":"somewhere"}}' http://127.0.0.1:8000/api/user/

But I am getting:

 Django Version:   1.4
 Exception Type:   IntegrityError
 Exception Value:
 null value in column "user_id" violates not-null constraint

It seems like a chicken and egg scenario. I need the user_id to create the UserProfileResource and I need the profile to create the UserResource. Obviously I am doing something very silly.

Can anyone out there shine a light? Many thanks johnoc

I modified my code as Pablo suggested below.

class UserProfileResource(StssRessource):
    home_address = fields.CharField(attribute='home_address')
    user = fields.ToOneField('resources.UserResource', attribute='user', related_name='profile')

    class Meta:
        queryset = UserProfile.objects.all()
        resource_name = 'profile'


class UserResource(ModelResource):
    profile = fields.ToOneField('resources.UserProfileResource', attribute='profile', related_name = 'user', full=True)
    class Meta:
        queryset = User.objects.all()
        resource_name = 'user'

But am getting :

 Django Version:   1.4
 Exception Type:   DoesNotExist

Which relates to trying to access the User resource in the ORM and it not existing while its creating the related_objects UserProfileResource. Which is correct. The User ORM isnt created until after the related_objects have been created.

Anyone else seen this??

Wylma answered 14/5, 2012 at 11:30 Comment(1)
I have the same problem here.Hemispheroid
H
14

After 2 days I finally managed to save related resources, the problem was that you have to specify both sides of the relation and their related names, in your case it would be something like that:

class UserProfileResource(ModelResource):
    home_address = fields.CharField(attribute='home_address')
    user = fields.ToOneField('path.to.api.UserResource', attribute='user', related_name='profile')
         #in my case it was a toManyField, I don't know if toOneField works here, you can try toManyField.

class UserResource(ModelResource):
    profile = fields.ToOneField(UserProfileResource, 'profile', related_name='user', full=True)
Hemispheroid answered 14/5, 2012 at 12:41 Comment(4)
Thanks Pablo, its been 2 days for me too! Much appreciated.Wylma
Actually I may have spoke too soon. It looked like it did work when I ran a POST. But when I tried again I get the following: The 'user' field has no data and doesn't allow a null value.Wylma
To clarify, I have it working now but I had to make the user filed a "ToManyField" (as Pablo suggested in his comment). And I also had to add "null=True" to the profile field. Otherwise the GET operations would not work. Anyways its all better now. Thanks for all your help Pablo!Wylma
How did you come up with this? Why would it make any difference? Are you defining a RelatedField on your opposing resource even though it's model does not have a corresponding relation? I don't have a corresponding relation, and your suggestion doesn't seem to have any effect for me. I feel like the underlying problem is something else.Erl
O
0

EDIT #2: Finally figured out how to fix things, but unfortunately it requires a bit of subclassing and overrides. Here's how I got it working:

First, create a new field subclass - I called my RelatedToOneField:

from tastypie.bundle import Bundle
from django.core.exceptions import ObjectDoesNotExist, MultipleObjectsReturned
from tastypie.exceptions import ApiFieldError, NotFound
class RelatedToOneField(fields.RelatedField):
    """
    Provides access to related data via foreign key.

    This subclass requires Django's ORM layer to work properly.
    """
    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=fields.NOT_PROVIDED,
                 null=False, blank=False, readonly=False, full=False,
                 unique=False, help_text=None):
        super(RelatedToOneField, 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):
        try:
            foreign_obj = getattr(bundle.obj, self.attribute)
        except ObjectDoesNotExist:
            foreign_obj = None

        if not foreign_obj:
            if not self.null:
                raise ApiFieldError("The model '%r' has an empty attribute '%s' and doesn't allow a null value." % (bundle.obj, self.attribute))

            return None

        self.fk_resource = self.get_related_resource(foreign_obj)
        fk_bundle = Bundle(obj=foreign_obj, request=bundle.request)
        return self.dehydrate_related(fk_bundle, self.fk_resource)

    def hydrate(self, bundle):
        value = super(RelatedToOneField, self).hydrate(bundle)

        if value is None:
            return value
        # START OF MODIFIED CONTENT
        kwargs = {
            'request': bundle.request,
        }

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

        return self.build_related_resource(value, **kwargs)
        #return self.build_related_resource(value, request=bundle.request)
        #END OF MODIFIED CONTENT

Then override the obj_create & save_related functions in your "top" model, or in this case, UserResource. Here's the relevant overrides:

def obj_create(self, bundle, request=None, **kwargs):
    """
    A ORM-specific implementation of ``obj_create``.
    """

    bundle.obj = self._meta.object_class()

    for key, value in kwargs.items():
        setattr(bundle.obj, key, value)

    bundle = self.full_hydrate(bundle)

    # Save the main object.
    # THIS HAS BEEN MOVED ABOVE self.save_related().
    bundle.obj.save()

    # Save FKs just in case.
    self.save_related(bundle)

    # Now pick up the M2M bits.
    m2m_bundle = self.hydrate_m2m(bundle)
    self.save_m2m(m2m_bundle)
    return bundle

def save_related(self, bundle):
    """
    Handles the saving of related non-M2M data.

    Calling assigning ``child.parent = parent`` & then calling
    ``Child.save`` isn't good enough to make sure the ``parent``
    is saved.

    To get around this, we go through all our related fields &
    call ``save`` on them if they have related, non-M2M data.
    M2M data is handled by the ``ModelResource.save_m2m`` method.
    """

    for field_name, field_object in self.fields.items():
        if not getattr(field_object, 'is_related', False):
            continue

        if getattr(field_object, 'is_m2m', False):
            continue

        if not field_object.attribute:
            continue

        # Get the object.
        # THIS HAS BEEN MOVED ABOVE the field_object.blank CHECK
        try:
            related_obj = getattr(bundle.obj, field_object.attribute)
        except ObjectDoesNotExist:
            related_obj = None

        # THE 'not related_obj' CHECK HAS BEEN ADDED
        if field_object.blank and not related_obj: # ADDED
            continue

        # Because sometimes it's ``None`` & that's OK.
        if related_obj:
            # THIS HAS BEEN ADDED
            setattr(related_obj, field_object.related_name, bundle.obj) # ADDED

            related_obj.save()
            setattr(bundle.obj, field_object.attribute, related_obj)

After you add those to your API, everything should work (At least on 0.9.11). The primary part of the fix is the related_obj's weren't be added properly for ToOneField's. My RelatedToOneField subclass implements this check into the field hydrate code.

EDIT: I was wrong again, ToOneField's still don't work in 0.9.12. My gotcha was that there was already a UserProfileResource with the same data I was trying to post in the database. It just grabbed that row and modified it instead of creating something new.


After also spending way too much time on this, it seems that there was a bug for ToOneField's that was fixed in version 0.9.12 (see comments in Pablo's accepted answer for relevant discussion).

If django-tastypie >= 0.9.12, the following should work:

class UserResource(ModelResource):
    profile = fields.ToOneField('path.to.api.UserProfileResource', 'profile', related_name='user', full=True)

class UserProfileResource(ModelResource):
    home_address = fields.CharField(attribute='home_address')
    user = fields.ToOneField(UserResource, attribute='user', related_name='profile')

if django-tastypie <0.9.12, you'll need to do the following:

class UserResource(ModelResource):
    profile = fields.ToOneField('path.to.api.UserProfileResource', 'profile', related_name='user', full=True)

class UserProfileResource(ModelResource):
    home_address = fields.CharField(attribute='home_address')
    user = fields.ToManyField(UserResource, attribute='user', related_name='profile')

Note: switched the order of UserResource & UserProfileResource since that made more sense for my mental model.

Ole answered 19/6, 2015 at 0:38 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.