SecNot

feb 11, 2014

Problemas con ImageField en Django

ImageField proporciona una buena abstracción para la gestión imágenes, hace fácil la subida de los archivos a la pagina, y proporciona tags para mostrarlas en los template. Pero ciertos comportamientos por defecto del campo, hacen imposible usarlo sin modificaciones en prácticamente cualquier proyecto django.

Estas son algunas de las modificaciones que suelo hacer en casi cualquier modelo con un campo ImageField.

Cambiar el nombre del archivo antes de guardarlo

Cuando ImageField salva la imagen, lo hace en un archivo con el mismo nombre que el archivo original, esto es un problema puesto que si se salvan dos imágenes con el mismo nombre una sobreescribe a la otra.

Este comportamiento es admisible un blog, donde el autor puede controlar el nombre de cada imagen, en cambio en una página donde los usuarios puedan subir imágenes, no tardaría en ser un problema grave.

La solución es usar el parámetro de ImageField upload_to, para proporcionar un nombre de archivo aleatorio con el que salvar la imagen.

from django.db import models
from uuid import uuid4
from datetime import date
import os

class Post(models.Model):

    def _generar_ruta_imagen(instance, filename):
        # El primer paso es extraer la extension de la imagen del
        # archivo original
        extension = os.path.splitext(filename)[1][1:]

        # Generamos la ruta relativa a MEDIA_ROOT donde almacenar
        # el archivo, usando la fecha actual (año/mes)
        ruta = os.path.join('Imagenes', date.today().strftime("%Y/%m"))

        # Generamos el nombre del archivo con un identificador
        # aleatorio, y la extension del archivo original.
        nombre_archivo = '{}.{}'.format(uuid4().hex, extension)

        # Devolvermos la ruta completa
        return os.path.join(ruta, nombre_archivo)

    imagen = models.ImageField(upload_to=_generar_ruta_imagen)

    text = models.TextField(max_lenght=1000)

La función _generar_ruta_imagen es llamada la primera vez que se salva la imagen para generar la ruta. Si estudias su código, verás que ademas de generar el nombre del archivo, generamos la ruta donde es almacenado, esta ruta se genera con la fecha en el momento de salvado, de manera que cambia con el paso del tiempo. Con esto se evita que miles de imágenes se guarden en un solo directorio, haciéndolo difícil de listar y manipular.

Usar un Mixin para añadir imágenes a varios modelos

En el caso de que tengamos varios modelos que necesiten una imagen, podemos crear una clase abstracta que proporciona un campo imagen, que genere de forma correcta las rutas y nombres. Luego solo tenemos que heredar de ella en cada modelo que necesite esa funcionalidad.

from django.db import models
from uuid import uuid4
from datetime import date
import os

class ImagenMixin(models.Model):

    def _generar_ruta_imagen(instance, filename):
        # El primer paso es extraer la extension de la imagen del
        # archivo original
        extension = os.path.splitext(filename)[1][1:]

        # Generamos la ruta relativa a MEDIA_ROOT donde almacenar
        # el archivo, se usa el nombre de la clase y la fecha actual.
        directorio_clase = instance.__class__.__name__
        ruta = os.path.join('imagenes', directorio_clase,
            date.today().strftime("%Y/%m"))

        # Generamos el nombre del archivo con un identificador
        # aleatorio, y la extension del archivo original.
        nombre_archivo = '{}.{}'.format(uuid4().hex, extension)

        # Devolvermos la ruta completa
        return os.path.join(ruta, nombre_archivo)

    imagen = models.ImageField(upload_to=_generar_ruta_imagen)

    class Meta:
        abstract = True

class Post(ImagenMixin):
    text = models.TextField(max_lenght=1000)

class Libro(ImagenMixin):
    titulo = models.CharField(max_length=100)

El único cambio está en _generar_ruta_imagen de la clase abstracta, donde se añade a la ruta de salvado el nombre de la clase. De esta manera se separan las imágenes salvadas por cada clase en un directorio distinto. Por ejemplo los modelos Post y Libro guardarían sus imágenes en:

  • /MEDIA_ROOT/imagenes/Post/año/mes/
  • /MEDIA_ROOT/imagenes/Libro/año/mes/

Mostrar imágenes en el interfaz Admin

Si usas admin para añadir contenido a tu página web, es realmente incómodo no poder ver las imágenes directamente en su interfaz. Para solucionarlo creamos un método en el modelo, que devuelve un tag <img> apuntando a la dirección de la imagen:

from django.db import models
from django.utils.safestring import mark_safe


class Post(models.Model):

    imagen = models.ImageField()
    text = models.TextField(max_length=1000)

    def imagen_admin(self):
        if self.image:
            # Marcamos imagen como safe para evitar escape automatico
            return mark_safe(u'<img src="%s" />' % self.imagen.url))
        else:
            return '(Sin imagen)'

    # Para cambiar el nombre del campo en pantalla
    imagen_admin.short_description = 'Imagen'

    # En lugar de mark_safe podemos añadir:
    imagen_admin.allow_tags = True

Y modificamos el ModelAdmin del modelo para que muestre la imagen. Hay que asegurarse de añadir el nuevo método a readonly_fields:

from django.contrib import admin
from blog.models import Post

class PostAdmin(admin.ModelAdmin):
    # imagen_admin tiene que ser siempre readonly, si queremos modificar
    # la imagen hay que hacerlo a traves del campo imagen.
    readonly_fields = ('imagen_admin',)
    fields = ('imagen_admin', 'imagen', 'text',)

admin.site.register(Post, PostAdmin)

Si no quieres modificar tu modelo, puedes crear un método similar directamente en ModelAdmin:

from django.contrib import admin
from blog.models import Post

class PostAdmin(admin.ModelAdmin):

    def imagen_admin(self, obj):
        return u'<img src="%s" />' % obj.image.url

    imagen_admin.allow_tags = True

    readonly_fields = ('imagen_admin',)
    fields = ('imagen_admin', 'text')

admin.site.register(Post, PostAdmin)

Por último si estás usando el mixin de la sección anterior, ese es el mejor lugar donde añadir el método.

Eliminar el archivo de la imagen al borrar el modelo

El comportamiento por defecto de ImageField, es no borrar el archivo cuando el modelo es eliminado, para borrarlo es necesario conectarse a la señal pre_delete que se envía antes de la eliminación de cualquier modelo, y hacerlo manualmente.

from django.db import models
from django.db.models.signals import pre_delete, pre_save
from django.dispatch import receiver


class Post(models.Model):
    imagen = models.ImageField()
    text = models.TextField(max_length=1000)

# Usamos el Decorador receiver para ejecutar nuestra función
# cuando el Post el borrado.
@receiver(pre_delete, sender=Post)
def post_pre_delete_handler(sender, instance, **kwargs):
    instance.imagen.delete(False)

Ten en cuenta que si borras el archivo con el modelo, puedes perder datos si hay algún accidente, si el numero de imágenes almacenado no es grande es mejor no borrarlas. Si el número es grande, puedes usar una tarea asíncrona que borre los archivos huérfanos tras borrar los modelos, esto lo puedes conseguir con celery, cron, o similar.