Implementar carga de archivos entre ember y django

Introducción

Separar frontend y backend como idea principal a la hora de hacer sistemas web tiene sus desafíos. Uno de esos desafíos consiste en implementar una forma simple y segura de subir archivos a través de un formulario web y una json-api.

En este artículo queremos mostrar una implementación completa, de principio a fin, de un sistema de subida de archivos usando nuestras dos herramientas favoritas en Enjambre Bit: Ember y Django (+ rest-framework).

Paso a paso: ¿que queremos mostrarte?

Vamos a ver paso a paso como implementar un formulario similar al siguiente:

En este artículo vamos a describir varios pasos, desde la instalación de los entornos, pasando por la creación de los modelos hasta la implementación de la API.

Sentite libre de abordarlo en la sección que quieras, preferimos armar el tutorial lo más detallado posible, de principio a fin, pero puede que en tu caso solo sea necesario abordar algunas partes puntales. Por ejemplo, si ya tienes una api implementada con json-api posiblemente las últimas dos secciones sean las únicas que necesites leer.

Posiblemente también te resulte útil investigar el repositorio que armamos con el paso a paso de este tutorial:

1 - Creando el proyecto ...

El primer paso consiste en crear la estructura completa del backend para almacenar registros con archivos adjuntos.

Estos son los pasos a seguir:

virtualenv venv --no-site-packages  
. venv/bin/activate.fish
pip install dj-static djangorestframework-jsonapi djangorestframework  
pip install django-cors-headers Django  
django-admin.py startproject backend .  

y luego, para que el entorno que creamos quede reproducible en otro entorno es aconsejable crear un listado de dependencias así:

pip freeze > requirements.txt  

Luego, tenemos que configurar algunas cosas. Por ejemplo, como instalamos dj-static es importante editar el archivo backend/settings.py para que tenga las rutas a los archivos estáticos y media (lo que los usuarios pueden crear):

STATIC_ROOT = 'staticfiles'  
STATIC_URL = '/static/'

MEDIA_ROOT = 'media'  
MEDIA_URL = '/media/'  

Y también hay que editar el archivo backend/wsgi.py para que su contenido completo sea este:

from django.core.wsgi import get_wsgi_application  
from dj_static import Cling, MediaCling

application = Cling(MediaCling(get_wsgi_application()))  

Si estás usando dokku u otro sistemas de deploy similar, es importante definir el valor de la variable MEDIA_ROOT con una ruta que los usuarios puedan escribir, y que forme parte de un dispositivo persistente entre deploys (por ejemplo /storage/appname o similar)

2 - Creación de la aplicación

Dentro del entorno virtual del backend hay que ejecutar lo siguiente:

django-admin.py startapp discos  

A continuación tenemos que editar el archivo backend/settings.py para habilitar la aplicación discos correctamente:

INSTALLED_APPS = [  
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'discos',
]

y luego, preparar la base de datos y poner en funcionamiento el servidor:

python manage.py migrate  
python manage.py createsuperuser  
python manage.py runserver  

Con esto deberíamos poder acceder a la aplicación y ver un mensaje
de aplicación incompleta así:

Ahora armemos un modelo, hay que editar el archivo discos/models.py y agregar lo siguiente:

class Disco(models.Model):  
    titulo = models.CharField(max_length=512)
    artista = models.CharField(max_length=512)
    portada = models.FileField()

    class Meta:
        db_table = "discos"
        verbose_name_plural = "discos"

    class JSONAPIMeta:
        resource_name = "discos"

    def __str__(self):
        return u"%s - %s" %(self.artista, self.titulo)

y activarlo en el archivo discos/admin.py:

# -*- coding: utf-8 -*-
from __future__ import unicode_literals

from django.contrib import admin

from discos import models

admin.site.register(models.Disco)  

Luego crear y ejecutar las migraciones:

python manage.py makemigrations  
python manage.py migrate  

Y con eso, ya vamos a poder ingresar en la URL http://localhost:8000/admin/ y ver nuestro módulo listo para editar:

Ahora, usando el admin se puede crear un registro de prueba, adjuntarle
una imagen y pasar a implementar la api:

El administrador de django es ideal para cargar datos iniciales o tener un segundo sistema de administración a la hora de corregir errores. Pero generalmente no le damos acceso a los clientes directamente, sino a través del frontend.

El siguiente paso es implementar una forma de acercarnos a esa arquitectura. Tener una api:

3 - Implementando una API básica

Para que nuestro futuro frontend pueda acceder a la lista de discos y subir información necesitamos implementar una API en el backend.

Para eso vamos a activar Django Rest Framework en nuestro archivo backend/settings.py así:

INSTALLED_APPS = [  
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'discos',
    'rest_framework',
]

Luego hay que crear estos dos archivos, el serializador y la vista:

discos/serializers.py:

from rest_framework import serializers  
import models


class DiscoSerializer(serializers.HyperlinkedModelSerializer):

    class Meta:
        model = models.Disco
        fields = "__all__"

discos/views.py:

# -*- coding: utf-8 -*-
from __future__ import unicode_literals

from django.shortcuts import render



from rest_framework import viewsets  
import models  
import serializers


class DiscoViewSet(viewsets.ModelViewSet):  
    queryset = models.Disco.objects.all()
    serializer_class = serializers.DiscoSerializer

y editar el archivo backend/urls.py:

from django.conf.urls import url, include  
from django.contrib import admin  
from rest_framework import routers

from discos import views

router = routers.DefaultRouter(trailing_slash=False)  
router.register(r'discos', views.DiscoViewSet)

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

Con estos cambios ya vamos a poder acceder a la API usando esta dirección http://127.0.0.1:8000/api/.

Incluso podemos acceder a cada recurso pulsando directamente en las URLs que expone:

O solicitar los datos de la api directamente sin contenido HTML, solo como datos:

curl http://127.0.0.1:8000/api/discos

[{"url":"http://127.0.0.1:8000/api/discos/1","titulo":"Guiding Lights","artista":"Skyharbor","portada":"http://127.0.0.1:8000/media/a3919737527_10.jpg"}]

4 - Implementando JSON-API

Hasta aquí, la estructura de las respuestas que retorna la API son muy simples, un diccionario de valores con el contenido de cada registro disco.

Pero hay varios detalles de implementación que quedarían indefinidos en este punto. ¿Cómo implementamos paginación?, ¿De qué forma vamos a parametrizar búsquedas en el futuro?, ¿el frontend deberá interpretar esta estructura de respuestas o hay que adaptarlo?

Aquí es donde entra en juego la especificación JSON-API, que agrupó esfuerzos para definir todos estos detalles por completo y tener resueltas estas desiciones a la hora de construir una API.

Esta especificación se puede implementar fácilmente en django, y a la vez viene implementada en ember-js, así que la comunicación entre backend y frontend no requerirá mucho esfuerzo de nuestra parte.

Para implementar la especificación JSON-API en django tenemos que editar el archivo backend/settings.py así:

APPEND_SLASH = False

REST_FRAMEWORK = {  
    # 'PAGE_SIZE': 10,
    'EXCEPTION_HANDLER': 'rest_framework_json_api.exceptions.exception_handler',
    # 'DEFAULT_PAGINATION_CLASS':
    #     'rest_framework_json_api.pagination.PageNumberPagination',
    'DEFAULT_PARSER_CLASSES': (
        'rest_framework_json_api.parsers.JSONParser',
        'rest_framework.parsers.FormParser',
        'rest_framework.parsers.MultiPartParser'
    ),
    'DEFAULT_RENDERER_CLASSES': (
        'rest_framework_json_api.renderers.JSONRenderer',
        'rest_framework.renderers.BrowsableAPIRenderer',
    ),
    'DEFAULT_METADATA_CLASS': 'rest_framework_json_api.metadata.JSONAPIMetadata',
}

Con ese cambio, veamos como cambió la api:

Antes se veía de la siguiente forma:

GET /api/discos

HTTP 200 OK  
Allow: GET, POST, HEAD, OPTIONS  
Content-Type: application/json  
Vary: Accept

[
    {
        "url": "http://127.0.0.1:8000/api/discos/1",
        "titulo": "Burn The Witch",
        "artista": "Radiohead",
        "portada": "http://127.0.0.1:8000/media/album_cover_Jegu38G.jpg"
    }
]

y ahora se verá así:

HTTP 200 OK  
Allow: GET, POST, HEAD, OPTIONS  
Content-Type: application/vnd.api+json  
Vary: Accept

{
    "data": [
        {
            "type": "discos",
            "id": "1",
            "attributes": {
                "titulo": "Guiding Lights",
                "artista": "Skyharbor",
                "portada": "http://127.0.0.1:8000/media/a3919737527_10.jpg"
            },
            "links": {
                "self": "http://127.0.0.1:8000/api/discos/1"
            }
        }
    ]
}

NOTA: Ten en cuenta que en este ejemplo sencillo quitamos la configuración de paginación. Si vas a tener muchos registros es conveniente que habilites esas primeras 3 lineas de configuración de rest-framework que nosotros incluimos inhabilitadas.

5 - Un avance en el frontend

Antes de avanzar en completar el backend, veamos como armar la estructura inicial del frontend, al menos para que se puedan leer y mostrar en pantalla
los discos almacenados en el backend.

Ejecutá estos comandos:

ember new frontend  
cd frontend

ember install semantic-ui-ember  

Si te resulta útil, podrías dejar en funcionamiento el servidor web de ember, ya que se va a actualizar automáticamente con cada cambio que realicemos (y va a a actualizar el navegador también):

ember s --proxy http://127.0.0.1:8000  

Luego, reemplacemos la pantalla inicial de ember con algo de código nuestro, primero armemos un modelo de disco y una ruta:

ember g model disco titulo:string artista:string portada:string  
ember g route index  

eliminemos la llamada a welcome-page desde el archivo app/templates/application.hbs para que quede así:

{{outlet}

y sustituyamos el contenido del archivo 'app/routes/index.js' para que solicite los modelos al backend:

import Ember from "ember";

export default Ember.Route.extend({  
  model() {
    return this.get("store").findAll("disco");
  }
});

También deberíamos crear un adaptador para indicarle a ember que la api está en un namespace llamado api. Ejecutemos:

ember g adapter application  

y luego carguemos el contenido del archivo app/adapters/application.js para que indique la ruta así:

import DS from 'ember-data';

export default DS.JSONAPIAdapter.extend({  
   namespace: 'api'
});

Luego, tenemos que hacer un componente nuevo para representar cómo se tiene que ver el disco:

ember g component demo-disco  

Con estos archivos:

app/templates/components/demo-disco.hbs:

{{yield}}

<img src={{disco.portada}}>

<p class="titulo">{{disco.titulo}}</p>  
<p class="artista">{{disco.artista}}</p>  

app/components/demo-disco.js:

import Ember from "ember";

export default Ember.Component.extend({  
  classNames: "demo-disco-container"
});

app/styles/app.css

body {  
  padding: 1em;
}

.demo-disco-container {
  display: inline-block;
  width: 200px;
  background: white;
  border: 1px solid #ddd;
  padding: 0.5em;
  vertical-align: top;
  margin: 3px;
  height: 250px;
}

.demo-disco-container img {
  width: 100%;
}

.demo-disco-container .titulo {
  font-weight: bold;
  margin: 2px;
}

.demo-disco-container .artista {
  color: gray;
  margin: 2px;
}

y lo invocamos para mostrar en el template de app/templates/index.hbs:

{{#each model as |disco|}}
  {{demo-disco disco=disco}}
{{else}}
  <p>No hay discos cargados.</p>
{{/each}}

Por último, podemos ver el navegador y contemplar cómo queda la vista de discos con un solo disco:

incluso podemos abrir el inspector de ember y ver que los datos desde la API se interpretaron correctamente:

6 - Creando el formulario para cargar discos

Con esta estructura, podemos empezar a implementar la ruta para cargar discos, primero tenemos que crear una ruta dedicada a eso:

ember g route cargarDisco  

y luego editar los archivos así:

app/templates/index.hbs:

<p>  
  {{#link-to 'cargarDisco' tagName='button' class="button"}}Cargar un disco nuevo{{/link-to}}
</p>

{{#each model as |disco|}}
  {{demo-disco disco=disco}}
{{else}}
  <p>No hay discos cargados.</p>
{{/each}}

{{outlet}}

Deberíamos ver el nuevo botón que nos llevará a la ruta nueva:

Para completar la ruta nueva necesitamos incorporar un formulario.

Ember tiene varios addons que nos permiten implementar formularios, nuestro favorito es ember-validated-form así que vamos a instalar ese junto a otros complementos más:

ember install ember-validated-form  
ember install ember-semantic-ui-file-uploader  
ember install emberx-file-input  
ember install ember-route-action-helper  

Luego tenemos que editar el archivo config/environment.js para que los formularios se vean correctamente
en el framework css que estamos usando (semantic-ui):

var ENV = {  
  // ...
  'ember-validated-form': {
    label: {
      submit: 'Go for it!',
    },
    css: {
      // semantic ui classes
      form: 'ui form',
      radio: 'ui radio',
      help: 'ui pointing red basic label',
      checkbox: 'ui checkbox',
      button: 'ui button',
      group: 'field',
      error: 'error'
    }
  },
  // ...
}

En este punto, lo ideal es agrupar el formulario completo dentro de un componente.

Ejecutá:

ember g component demo-formulario/disco  

y completá los archivos generados por ember con el siguiente código:

app/components/demo-formulario/disco.js

import Ember from "ember";

export default Ember.Component.extend({  
  errors: [],

  guardarErrores(erroresDelAdaptador) {
    let erroresConvertidos = erroresDelAdaptador.errors.map(error => {
      return {
        detalle: error.detail,
        campo: error.source.pointer.split("/").pop()
      };
    });

    this.set("errors", erroresConvertidos);
  },

  actions: {
    submit(model) {
      model
        .save()
        .then(() => {
          this.sendAction("cuandoGuarda");
        })
        .catch(error => this.guardarErrores(error));
    },

    onUpload(file, extraArgumentForUpload) {
      extraArgumentForUpload.update({
        nombre: file.name,
        contenido: file.result
      });
    }
  }
});

app/templates/components/demo-formulario/disco.hbs

{{yield}}

{{#validated-form
  model        = (changeset model model.validaciones)
  on-submit    = (action "submit")
  submit-label = 'Save' as |f|}}

  {{f.input label="Artista" name="artista"}}
  {{f.input label="Título" name="titulo"}}

  {{#f.input label="Portada" name="portada" as |fi|}}
    {{file-input onUpload=(action 'onUpload') extraArgumentForUpload=fi}}
  {{/f.input}}


  {{#if errors}}
    <div class="ui negative message">
      {{#each errors as |error|}}
        <p><strong>{{error.campo}}</strong> - {{error.detalle}}</p>
      {{/each}}
    </div>
  {{/if}}

  {{f.submit label="Guardar"}}

{{/validated-form}}

Las validaciones para aplicar en el formulario pueden estar en el componente
de formulario o directamente dentro del modelo, nosotros los agregaremos en el modelo para que sea más sencillo de re-utilizar luego:

Editá el archivo models/disco.js con este contenido:

import DS from "ember-data";  
import { validatePresence } from "ember-changeset-validations/validators";

export default DS.Model.extend({  
  titulo: DS.attr("string"),
  artista: DS.attr("string"),
  portada: DS.attr("string"),

  validaciones: {
    titulo: [validatePresence(true)],
    artista: [validatePresence(true)],
    portada: [validatePresence(true)]
  }
});

Por último, para ver el formulario en pantalla tenemos que
completar la ruta cargarDisco editando estos dos archivos:

app/templates/cargar-disco.hbs:

{{demo-formulario/disco model=model cuandoGuarda=(route-action "regresar")}}

y app/routes/cargar-disco.js:

import Ember from "ember";

export default Ember.Route.extend({  
  model() {
    return this.store.createRecord("disco");
  },

  actions: {
    regresar() {
      this.transitionTo("index");
    },
    willTransition: function() {
      if (this.currentModel.get("isNew")) {
        this.get("currentModel").deleteRecord();
      }
    }
  }
});

7 - Implementando manejador de subida de archivos

Este es uno de los puntos más importantes, porque aún necesitamos indicarle a django que el campo portada es diferente a los demás, el campo portada tiene que tratarse como un campo especial, un objeto json (con nombre y contenido base64) que al llegar al backend se tiene que convertir en un archivo y guardarse en el disco.

En el serializador del backend (discos/serializers.py) ha que indicarle a rest-framework que no procese el campo portada:

from rest_framework import serializers  
import models


class DiscoSerializer(serializers.HyperlinkedModelSerializer):

    class Meta:
        model = models.Disco
        fields = "__all__"
        read_only_fields = ['portada']

Luego, tenemos que modificar un detalle en el frontend, tenemos que indicarle al modelo de ember que portada ya no se tiene que interpretar como un string:

import DS from "ember-data";  
import { validatePresence } from "ember-changeset-validations/validators";

export default DS.Model.extend({  
  titulo: DS.attr("string"),
  artista: DS.attr("string"),
  portada: DS.attr(),

  validaciones: {
    titulo: [validatePresence(true)],
    artista: [validatePresence(true)],
    portada: [validatePresence(true)]
  }
});

Cuando le indicamos a un campo de ember data que su tipo es simplemente DS.attr(), ember-data va a permitir que cualquier dato ingrese ahí tal y como viene desde la API, evitando cualquier tipo de conversión en el medio.

Por último, tenemos que configurar la vista de del recurso Disco para que atienda al campo portada de forma especial:

# -*- coding: utf-8 -*-
from __future__ import unicode_literals

from django.shortcuts import render

from rest_framework import viewsets  
import models  
import serializers

import base64  
import uuid  
from django.core.files.base import ContentFile

def decode_base64_file(data, nombre_de_archivo):  
    if 'data:' in data and ';base64,' in data:
        header, data = data.split(';base64,')

    decoded_file = base64.b64decode(data)
    complete_file_name = str(uuid.uuid4())[:12]+ "_" + nombre_de_archivo
    return ContentFile(decoded_file, name=complete_file_name)



class DiscoViewSet(viewsets.ModelViewSet):  
    queryset = models.Disco.objects.all()
    serializer_class = serializers.DiscoSerializer

    def perform_update(self, serializer):
        return self.guardar_modelo_con_archivo(serializer)

    def perform_create(self, serializer):
        return self.guardar_modelo_con_archivo(serializer)

    def guardar_modelo_con_archivo(self, serializer):
        instancia = serializer.save()
        portada = self.request.data.get('portada', None)

        if portada and isinstance(portada, dict):
            nombre = portada.get('nombre')
            contenido_base_64 = portada.get('contenido')
            instancia.portada = decode_base64_file(contenido_base_64, nombre)

        instancia.save()
        return instancia

Tene en cuenta que en este punto podemos hacer cualquier trabajo adicional que queramos con la imagen, por ejemplo generar una miniatura con sorl o similar.

Ajustes menores

Si bien el administrador de django tal vez solo lo veas en modo desarrollo, es fácil configurarlo para que se vea completamente en español. Solo hay que editar esta linea de código en el archivo backend/settings.py:

LANGUAGE_CODE = 'es'  

También podemos mejorar un poco más la experiencia del usuario agregando indicadores de carga, por ejemplo: cuando se está cargando la lista de discos.

Esto es tan simple como crear un archivo llamado app/templates/index-loading.hbs con este contenido:

<p>Cargando ...</p>  

O bien:

<div class="ui active inverted dimmer">  
  <div class="ui text loader">Cargando ... </div>
</div>  

Así, cuando la api demore en responder el usuario va a ver un indicador de actividad:

Otro mejora que seguramente quieras realizar es traducir los mensajes de validación de todos los formularios. Simplemente hay que crear el archivo app/validations/messages.js con el siguiente contenido:

export default {  
  inclusion: "{description} no está incluido en la lista",
  exclusion: "{description} está reservado",
  invalid: "{description} es inválido",
  confirmation: "{description} no coincide con {on}",
  accepted: "{description} debe ser aceptado",
  empty: "Este campo no puede esta vacío",
  blank: "Este campo no puede quedar en blanco",
  present: "Este campo no puede quedar en blanco",
  collection: "{description} tiene que ser una colección",
  singular: "{description} no puede ser una colección",
  tooLong: "{description} es muy largo (el máximo es de {max} letras)",
  tooShort: "Este campo es muy corto (tiene que tener al menos {min} letras)",
  between: "{description} debe estar entre {min} y {max} caracteres",
  before: "{description} debe ir antes de {before}",
  onOrBefore: "{description} debe ser o estar antes de {onOrBefore}",
  after: "{description} debe ir después de {after}",
  onOrAfter: "{description} debe ser o ir después de {onOrAfter}",
  wrongDateFormat: "{description} debe tener el formato {format}",
  wrongLength: "{description} tiene una longitud incorrecta (deben ser de {is} letras)",
  notANumber: "{description} tiene que ser un número",
  notAnInteger: "{description} tiene que ser un entero",
  greaterThan: "{description} tiene que ser mayor a {gt}",
  greaterThanOrEqualTo: "{description} debe ser mayor o igual a {gte}",
  equalTo: "{description} debe ser igual a {is}",
  lessThan: "{description} tiene que ser al menos {lt}",
  lessThanOrEqualTo: "{description} tiene que ser menor o igual que {lte}",
  otherThan: "{description} tiene que ser otro valor distinto a {value}",
  odd: "{description} tiene que ser impar",
  even: "{description} tiene que ser par",
  positive: "{description} tiene que ser positivo",
  multipleOf: "{description} tiene que ser múltiplo de {multipleOf}",
  date: "{description} tiene que ser una fecha válida",
  email: "{description} tiene que ser una dirección de email válida",
  phone: "{description} tiene que ser un número de teléfono válido",
  url: "{description} tiene que ser una url"
};

Y los mensajes van a quedar así:

Extra: Datos curiosos

Como recompensa por llegar aquí, te dejamos algunos datos fuera de tópico relacionados con este artículo:

  • Los archivos que se convirten a base64 previamente desde el navegador suelen ocupar un 30% más que su respectiva versión binaria. Es importante tenerlo en cuenta si llegamos a manejar archivos muy grandes.

  • La imagen de la portada no tiene nada que ver con subir archivos, es una captura de pantalla de un videojuego llamado The Talos Principle, que curiosamente es tan difícil como lograr una correcta implementación de subida de archivos de principio a fin. Nos pareció ilustrativa para intimidar al lector ocasional; sí, a veces somos malos :P