Skip to content
Aprende a hacer apps móviles con Ionic 2 

Django REST Framework: Serializers anidados y ModelViewSet con métodos custom

Ya os he hablado otras veces de Django REST Framework, un framework que me parece imprescindible si quereis que vuestro proyecto Django tenga una API REST. Si no estáis familiarizados con él, aquí podéis ver una introducción a Django REST framework.

A raíz de una serie de dudas que me exponía un lector habitual del blog sobre este framework, hoy quiero profundizar en como aprovechar el potencial de los ModelViewSets y los Serializers, centrándome en los siguientes casos:

Serializers anidados: Tengo un objeto que hace referencia a otro, y quiero que la salida me devuelva el objeto entero, en lugar de la referencia.
Parámetros de los serializers: Voy a ver como su flexibilidad me permite cambiar el tipo de campos que quiero en función del tipo de acceso, cambiar el nombre de un campo, etc
Métodos personalidos en un ModelViewSet: Aprovechando el caso anidado, veremos como se pueden crear métodos personalizados, más allá de los GET/POST/PUT/etc que ofrece el ModelViewSet por defecto.

 

Utilizaré Django 1.7, me basaré en el típico ejemplo de un blog con comentarios, y lo detallaré paso a paso.

Si quieres saltarte la creación del proyecto y demás, y quieres ir directo al código, al final de este artículo encontrarás el repositorio de github para clonártelo.

Creando el proyecto

Uso VirtualEnvWrapper para gestionar el entorno. Si no lo conoces, visita obligada a creando un proyecto con Django y virtualenv y VirtualenvWrapper.

Creo el entorno, proyecto y applicación blog donde tendré el modelo de datos:

miusuario$ mkvirtualenv blogWithSerializers
(blogWithSerializers)$ pip install django
(blogWithSerializers)$ django-admin.py startproject blogWithSerializers
(blogWithSerializers)$ cd blogWithSerializers
(blogWithSerializers)$ chmod u+x manage.py
(blogWithSerializers)$ ./manage.py startapp blog

Instalo también Django REST framework, y creo la aplicación api, donde meteré todo lo relativo a la API:

(blogWithSerializers)$ pip install djangorestframework
(blogWithSerializers)$ ./manage.py startapp api

Esto me deja la siguiente estructura:

blogWithSerializers
/manage.py
/blogWithSerializers
/__init__.py
/urls.py
/wsgi.py
/settings.py
/blog
/__init__.py
/admin.py
/models.py
/tests.py
/views.py
/api
/__init__.py
/admin.py
/models.py
/tests.py
/views.py

 

Añadiendo las apps

Para que el proyecto detecte las 2 apps que he creado, junto con la app de REST framework debo añadirlas al fichero settings.py:

INSTALLED_APPS = ( 
    'django.contrib.admin', 
    'django.contrib.auth', 
    'django.contrib.contenttypes', 
    'django.contrib.sessions', 
    'django.contrib.messages', 
    'django.contrib.staticfiles', 
    #here I add the installed apps
    'rest_framework', 
    'blog', 
    'api', 
)

 

Modelo del blog

Mi blog tendrá 3 clases básicas:

  • UserProfile: irá asociado a un User (modelo por defecto de Django), al que añadiré algún otro campo
  • Post: será un post del blog, irá vinculado a su creador, tendrá título, texto y comentarios
  • Comment: Tendrá una valoración y un texto, irá vinculado a un Post, y también será creado por un UserProfile

A continuación escribo el modelo en blog/models.py:

from django.contrib.auth.models import User
from django.db import models


class UserProfile(models.Model):
    user = models.OneToOneField(User, primary_key=True)
    karma = models.IntegerField(default=0, blank=True)
    def __str__(self):
        return self.user.username

class Post(models.Model):
    owner = models.ForeignKey(UserProfile)
    title = models.CharField(max_length=100)
    body = models.TextField()
    def __str__(self):
        return self.title

class Comment(models.Model):
    owner = models.ForeignKey(UserProfile)
    post = models.ForeignKey(Post)
    text = models.CharField(max_length=300)
    def __str__(self):
        return self.text

 

Creación de la base de datos

He creado un nuevo modelo, y tendré que decirle a Django que me prepare la base de datos para poderlo guardar. Recordemos que Django 1.7 incorpora por defecto la gestión de migraciones de BBDD, así que:

1) Preparo la migración

(blogWithSerializers)$ ./manage.py makemigrations

Si no he hecho nada mal, esto me da una salida del estilo:

Migrations for ‘blog’:
0001_initial.py:
– Create model Comment
– Create model Post
– Create model UserProfile
– Add field owner to post
– Add field owner to comment
– Add field post to comment

2) Acto seguido ejecuto la migración:

(blogWithSerializers)$ ./manage.py migrate

Y la consola me responde:

Operations to perform:
Synchronize unmigrated apps: rest_framework
Apply all migrations: admin, blog, contenttypes, auth, sessions
Synchronizing apps without migrations:
Creating tables…
Installing custom SQL…
Installing indexes…
Running migrations:
Applying contenttypes.0001_initial… OK
Applying auth.0001_initial… OK
Applying admin.0001_initial… OK
Applying blog.0001_initial… OK
Applying sessions.0001_initial… OK

Parece que voy por buen camino.

3) Sincronizo (y de paso, ya que no existía, creo) la base de datos

(blogWithSerializers)$ ./manage.py syncdb

El terminal empieza a gastar saliva diciendo lo que va a realizar, y de repente me comenta que si quiero crear un superusuario. Le digo que yes, medice nombre, email y password 2 veces, completo y me contesta que el Superusuario se ha creado correctamente.

De momento todo bien, lo estoy bordando: ya tengo la base de datos creada 😉

Creando un Panel de administración básico

Voy a centrar la API en lo que son los Posts, así que por algún lado voy a tener que crear usuarios, y eso será el panel de administración. Me pongo el mono de trabajo y abro blog/admin.py. Lo dejo como sigue:

from django.contrib import admin
from .models import UserProfile, Post, Comment

class UserProfileAdmin(admin.ModelAdmin):
    pass


class CommentInline(admin.TabularInline):
    model = Comment
    extra = 3

class PostAdmin(admin.ModelAdmin):
    inlines = [CommentInline, ]


admin.site.register(UserProfile, UserProfileAdmin)    
admin.site.register(Post, PostAdmin)

Además, abro blogWithSerializers/urls.py y la dejo así:

from django.conf.urls import patterns, include, url
from django.contrib import admin

admin.autodiscover()

urlpatterns = patterns('',
    url(r'^admin/', include(admin.site.urls)),
)

 

Probamos el panel de administración

Estoy ya impaciente por ejecutar el servidor y ver corretear a la criatura. De paso, crearé algunos usuarios para poder añadir Posts más adelante desde la API. Dale:

(blogWithSerializers)$ ./manage.py runserver

Aún con cosquilleo en el estómago, abro el navegador y me voy a esta url: http://127.0.0.1:8000/admin/

¡¡Parece que funciona!! Me pide el usuario y password que he creado antes con el syncdb. Lo meto y… voilà:

admin panel

Ahora amigos, os dejo unos minutos para Crear Users, User Profiles, e incluso si queréis, algún Post con sus comentarios, para ver que todo funciona correctamente.

Creando una API REST con ModelViewSet

¿Ya estáis aquí? Yo también. La verdad, creía que sería más divertido, pero a meter datos a través del panel de administración se le acaba la gracia pronto. ¡Vamos a crear una API señores!

Cuando has trabajado un poco con API REST Framework, te das cuenta de que los que controlan el cotarro son los ModelViewSet, hacen muchas cosas por defecto para ahorrarte líneas de código, pero siempre los puedes personalizar por que todos somos algo “especialitos”, y queremos las cosas a nuestra manera. Vamos al grano.

Que debe hacer mi API REST

Lo que yo quiero es poder hacer las siguientes cosas a través de mi API:

  • recuperar todo el listado de posts
  • recuperar un post concreto, con sus comentarios
  • crear un nuevo post
  • crear un nuevo comentario en un post

URLs

Como vemos, todo lo que quiero hacer, está relacionado con el recurso Post, incluso el comentario (es un recurso anidado), así que siguiendo patrones REST, debería acceder de este modo:

http://127.0.0.1:8000/api/v1/posts/
para obtener el listado de posts o crear un post

http://127.0.0.1:8000/api/v1/post/13444
donde 13444 será el número de Post al que quiero acceder

http://127.0.0.1:8000/api/v1/post/13444/comment
para añadir un comentario al post 13444

Dado que solo trabajo con el recurso Post, crearé un ModelViewSet llamado PostViewSet donde incluiré toda la magia, pero eso será más adelante. De momento, me pongo manos a la obra y creo el archivo api/urls.py:

from django.conf.urls import patterns, include, url 
from rest_framework.urlpatterns import format_suffix_patterns 
from . import views


post_list = views.PostViewSet.as_view({
    'get': 'list',
    'post': 'create'
})

post_detail = views.PostViewSet.as_view({
    'get': 'retrieve',
    'put': 'update',
    'patch': 'partial_update',
    'delete': 'destroy'
})

comment_creation = views.PostViewSet.as_view({
    'post': 'set_comment'
})


urlpatterns = patterns('api.views', 
   url(r'^v1/posts/$', post_list, name='post_list'), 
   url(r'^v1/post/(?P<pk>[0-9]+)/$', post_detail, name='post_detail'), 
   url(r'^v1/post/(?P<pk>[0-9]+)/comment/$', comment_creation, name='comment_creation'),
)


urlpatterns = format_suffix_patterns(urlpatterns)

Podemos ver como, por un lado, genero vistas relativas a mi futuro PostViewSet, donde vinculo distintos tipos de acceso (get/put/post, etc) a distintos métodos (algunos predefinidos como list o create, pero también a un método personalizado como set_comment que tendré que crear).

Acto seguido, vinculo las URLs con las vistas anteriores.

 

Incluyendo las URLs de ‘api’ en las URLs generales

Para que Django sepa encontrar mis urls, le voy a tener que decir donde encontrarlas, así que recupero el archivo blogWithSerializers/urls.py y lo dejo así:

from django.conf.urls import patterns, include, url
from django.contrib import admin

admin.autodiscover()

urlpatterns = patterns('',
    url(r'^api/', include('api.urls')),
    url(r'^admin/', include(admin.site.urls)),
)

 

Serializers

Si quiero parsear datos a través de la API REST, necesitaré unos Serializers, es decir, objetos que determinan qué campos y de que manera se traducen de cada modelo a la API y viceversa.

Aquí empieza la gracia. Si quiero serializar el Post, necesitaré una clase, que llamaré PostSerializer, donde le digo que campos quiero usar. Son estos:

  • id
  • title
  • body
  • owner
    • Al recibir Posts, quiero que me envíe el objeto entero, así que necesito un UserProfileSerializer
    • Por otro lado, cuando creo uno nuevo NO quiero enviar el objeto entero, sino solo el id
  • comments: Quiero que me envíe los comentarios con todo su contenido, por lo que necesitaré crear un CommentSerializer

 

Vamos a ver como queda esto en código blogWithSerializers/serializers.py:

from rest_framework import serializers
from blog.models import Post, Comment, UserProfile


class UserProfileSerializer(serializers.ModelSerializer):
    class Meta:
        model = UserProfile
        fields = ('user', 'karma')


class CommentSerializer(serializers.ModelSerializer):
    class Meta:
        model = Comment
        fields = ('text', 'owner')


class PostSerializer(serializers.ModelSerializer):
    owner = UserProfileSerializer(read_only=True)
    ownerId = serializers.PrimaryKeyRelatedField(write_only=True, queryset=UserProfile.objects.all(), source='owner')
    comments = CommentSerializer(many=True, read_only=True, source='comment_set')

    class Meta:
        model = Post
        fields = ('id', 'title', 'body', 'owner', 'ownerId', 'comments')

Fíjate, para referenciar un campo del modelo, uso el mismo nombre en fields.
Si quiero usar otro nombre, o quiero indicar un serializer concreto, lo hago fuera de la subclase Meta. PrimaryKeyRelatedField me devuelve el id, mientras que si quiero un objeto completo, lo indico con un serializer a medida para dicho objeto.

DETALLE 1: El problema de dualidad con el campo owner lo soluciono creando 2 fields distintos, uno será read_only, para la lectura, y el otro write_only, para la escritura.

DETALLE 2: El campo comments, hace referencia al array de comentarios ligados al Post, que puedo recuperar usando el nombre comments_set, PERO no quiero usar ese nombre así que le digo a quién hace referencia con el parámetro source. Atención también al parámetro many=True con el que le digo que es una colección.

Vistas

Ya tengo las clases que van a pasar mis modelos a JSON para sacarlo por la API, y tengo las URLs apuntando a ciertas vistas, pero voy a tener que crearlas para que la cosa funcione. Voy a api/views.py y lo edito:

from rest_framework import viewsets, status
from rest_framework.response import Response
from rest_framework.decorators import detail_route

from blog.models import Post, Comment, UserProfile 
from .serializers import PostSerializer, CommentSerializer, UserProfileSerializer


class PostViewSet(viewsets.ModelViewSet):
    queryset = Post.objects.all()
    serializer_class = PostSerializer

    @detail_route(methods=['post'])
    def set_comment(self, request, pk=None):

        #get post object
        my_post = self.get_object()  
        serializer = CommentSerializer(data=request.data)                 
        if serializer.is_valid():
            serializer.save(post=my_post)
            return Response(serializer.data, status=status.HTTP_201_CREATED)

        return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

Ojo al tema: Solo añadiendo el queryset y el serializer_class, ya tengo la api funcionando para crear, listar y recibir posts individuales.

Y entonces, ¿qué  #&!#%!!  es el resto?

Métodos personalizados

  • @detail_route es el decorador que me permite añadir un método personalizado, ligado a un objeto concreto.
  • @list_route sería el decorador que usaría, si quiero un método personalizado para trabajar con todo el listado de post.

En este caso, recupero el Post concreto al que hace referencia la url (recuerda: http://127.0.0.1:8000/api/v1/post/13444/comment), creo un serializer tipo comentario con los datos de la petición POST que he recibido, y en caso de que sea válido, lo asocio al objeto Post y devuelvo una respuesta conforme todo está bien.

Y ahora…dime…¿ganas de probarlo? ¡¡Vamos allá!!

Probando la API REST

Otras veces os he hablado del cliente para API REST Postman. Si me tengo que pelear con una API en serio, sin duda, es mi arma preferida, ¡apúntatelo!

Lo que no comento tan a menudo, es que el propio Django REST Framework incorpora un mecanismo de vistas asociadas a su API, así que, una vez has creado las vistas, puedes acceder a su pequeño cliente para probar la API.

Ejecutar el servidor

De nuevo, si no lo tenía ya en marcha, ejecuto por consola:

(blogWithSerializers)$ ./manage.py runserver

Probar listado de Posts

Ahora me voy raudo y veloz a http://127.0.0.1:8000/api/v1/posts/ y

¡¡sorpresa!!

¡Ahí están!
¡Los posts que he creado al principio desde el panel de administración!

Veamos:

API posts

Me encanta el detalle de que tanto en el array de comentarios, como en el owner, lo que tengo son objetos completos, y no solo su id. ¿Pero que pasará cuando quiera crear un post? ¿Tengo que meter todo el diccionario del usuario creador?

Crear un Post

Evidentemente NO. De eso va este artículo. Si te desplazas al final de la vista, verás el formulario para hacer POST a la API, y solo contiene los campos title, body y Ownerid (el cual me deja seleccionar entre los distintos usuarios que tengo creados). Este último caso, a efectos de la petición, es solo el número de identificador del usuario.

Lo gracioso de todo esto es que aún no he salido de la misma URL, sigo en http://127.0.0.1:8000/api/v1/posts/. Meto algunos datos en el formulario, le doy a POST y todo va fino como la seda. El resultado: Una respuesta de servidor 201, mi objeto ha sido creado.

POST a new Post through the API

Obtener un Post concreto

Esto no tiene más secreto. Me voy a la URL http://127.0.0.1:8000/api/v1/post/4/, y efectivamente, obtengo el Post concreto que acabo de crear.

GET specific Post

Crear un comentario (objetos anidados)

Ahora es cuando me la juego. La historia que os he vendido es que si me voy a http://127.0.0.1:8000/api/v1/post/4/comment/ Tendría que poder crear un nuevo comentario, que se asociará justamente al Post número 4.

De entrada (imagino que es un bug de API Rest Framework), si voy a la URL que acabo de citar, me sale un mensaje diciendo que el método no es válido (esto es correcto, ya que por defecto está haciendo un GET esta URL, cosa que no permito), y aparece un formulario como el de crear un nuevo blog. WTF!!!

Sin rellenar nada (para que perder el tiempo), le doy a POST, y… ¡ahora sí! Me sale de nuevo un mensaje de error, pero el formulario ha cambiado con los campos que se deben entrar para crear un comentario. Los relleno > POST > y ahí esta mi comentario!!

POSTING a comment to the Article

Jugando con el código

Para acabar, te dejo un enlace al repo de github por si quieres bajarte el código y jugar un poco con él. Recuerda instalar las dependencias del proyecto mediante pip install -r requirements.txt.

Si te ha gustado el artículo, compártelo 😉

¡¡Saludos!!

Published inDjangoDjango REST Framework

2 Comments

  1. hola estoy confundido en la jerarquía de la estructura de los directorios
    Solo me quede hasta el comando “./manage.py startapp api” y queria ver si quedaron bien mi estructura? antes de continuar, gracias.
    tengo lo siguiente:
    dentro de /home/myuser/django/
    blogWithSerializers <—-virtualenv
    ├───bin/
    ├───include/
    ├───lib/
    ├───pip-selfcheck.json
    blogWithSerializers/ <—-proyecto
    ├───manage.py
    ├───blog/ <—- app blog
    ├───__init__.py
    ├───admin.py
    ├───apps.py
    ├───migrations/
    ├───models.py
    ├───tests.py
    ├───views.py
    ├───api/ <—-app api
    ├───__init__.py
    ├───admin.py
    ├───apps.py
    ├───migrations/
    ├───models.py
    ├───tests.py
    ├───views.py
    ├───blogWithSerializers/ <—-proyecto
    ├───__init__.py
    ├───__pycache__/
    ├───settings.py <—- settings que modifique
    ├───urls.py
    ├───wsgi.py
    |

Deja un comentario