[Из песочницы] Хранение изображений с помощью Django/Django REST

Проблема

Мне в ходе разработки часто приходится работать с моделями, в которых должны быть изображения. Для удобной организации я использую древовидную структуру папок. В целом, Django предоставляет инструмент для работы с изображениями. Например, вот вопрос на Хабр Q&A о том, как работать с пикчами в Django: использовать ImageField

class Article(models.Model):
     title = models.CharField(max_length=255)
     content = models.TextField()
     img = models.ImageField(upload_to='/article', height_field=100, width_field=100)

Параметр upload_to указывает название папки, в которую нужно загрузить вашу пикчу. И получается, что в рантайме мы никак не сможем повлиять на место куда будет загружено ваше изображение. Выходит что для одной модели, все изображения будут складываться в одну папку. Беспорядок и непорядок какой-то в общем.

image


Основная идея

Создать свой класс Picture, в котором также будет использоваться ImageField, но upload_to будет реализовано в виде функции, которая будет возвращать нужное имя папки. Соответственно во всех моделях, где вам нужна картинка: использовать класс Picture. Также нужно придумать алгоритм построения дерева папок, так как мой пример, может не подойти конкретно вам. Для себя я определил, что у изображения будет владелец — компания, аккаунт etc. Будет тип изображения, обычно по названию модели, с которым сейчас работает. Доп. параметр, если мне для одной сущности, нужно хранить больше чем одно изображение или у владельца может быть несколько дочерних сущностей, которые должны в себе хранить картиночки.

image


Пример реализации на простом проекте

Схема БД будет всего из 4 моделей:

image

Остановимся на моделе News, другие модели практически идентичны:


развернуть
class News(models.Model):
    name = models.CharField(max_length=50, default='')
    text = models.TextField(default='')
    account = models.ForeignKey(Account, on_delete=models.CASCADE)
    company = models.ForeignKey(Company, on_delete=models.SET_NULL, null=None)
    image = models.ForeignKey(Picture, on_delete=models.SET_NULL, null=True)

    def set_image(self, image):
        if self.image is not None:
            self.image.delete()
        self.image = Picture.upload_image(owner=self.company.id, image=image, owner_type='company',
                                          picture_type='news', base=self.id)
        self.save()

Так как новость относится к компании, то ее нужно положить в папку компаний (owner_type='company'), положить нужно в папку именно той компании, которой новость принадлежит (owner=self.company.id).

Скорее всего такой метод придется писать ко всем моделям, особенно если вам нужно определять дополнительную логику работы с изображением.

Рассмотрим код класса Picture:


развернуть код
def upload_to(instance, filename):
    relative_path = instance.url_to_upload.rfind('images/') + len("images/")
    return instance.url_to_upload[relative_path:]

class Picture(models.Model):
    local_url = models.ImageField(upload_to=upload_to)
    url_to_upload = models.CharField(max_length=200, default='')

    @staticmethod
    def upload_image(owner, owner_type, picture_type, image, base=""):
        image_name = Picture.get_uuid_name_with_extension(image)
        picture = Picture.objects.create(
            local_url=image,
            url_to_upload=Uploader.get_path(owner, owner_type, picture_type, image_name, base_for_file=base))
        return picture

    def delete(self, using=None, keep_parents=False):
        os.remove(self.url_to_upload)
        super().delete(using=using, keep_parents=keep_parents)

local_url собственно нужен чтобы хранить валидный для ImageField путь до папки.
url_to_upload — хранит абсолютный путь чтобы вы могли дропать картинки из файловой системы.

Генерация uuid вместо имени я выбрал специально, чтобы разные картинки, но с одним и тем же именем, в конечном итоге имели разные имена, например, чтобы на фронтенде при загрузки аватарки у пользователя, она сразу перерисовывалась. Если имя не изменится, то нужно будет ребутить страницу или писать какую-то доп. логику, чего не хотелось бы.

В функции upload_to я обрезаю лишний путь, чтобы ImageField корректно отображал путь до файла.

Uploader — это вспомогательный класс, который просто возвращает полный путь папки, в которую нужно поместить пикчу.


Реализация класса Uploader
import os
from project.settings import MEDIA_ROOT, BASE_DIR

class Uploader:

    @staticmethod
    def get_or_create_path(name):
        try:
            os.mkdir(str(name))
        except Exception as e:
            print(e)
        finally:
            return str(name)

    @staticmethod
    def get_path(owner, owner_type, picture_type, filename, base_for_file=''):
        os.chdir(MEDIA_ROOT)
        os.chdir(Uploader.get_or_create_path(owner_type))
        os.chdir(Uploader.get_or_create_path(owner))
        if picture_type:
            os.chdir(Uploader.get_or_create_path(picture_type))
        if base_for_file:
            os.chdir(Uploader.get_or_create_path(base_for_file))
        return os.getcwd() + '/' + filename

Здесь ничего нет интересно: создаются папки, если их нет для правильного создания дерева. У вас эта реализация можешь быть совершенно другая. Но, полагаю, что мой вариант покрывает большое количество вариантов.


Минусы такого подхода

Главный минус такого подхода по сравнению с юзанием ImageField — необходимость писать некоторый код в ваших вьюхах например так выглядит моя вьюха для модели News.


развернуть
class NewsView(mixins.RetrieveModelMixin,
               mixins.UpdateModelMixin,
               mixins.ListModelMixin,
               mixins.CreateModelMixin,
               viewsets.GenericViewSet):
    serializer_class = NewsSerializer
    queryset = News.objects.all()

    def set_image_from_request(self, news):
        image = self.request.data.get('image')
        if image is not None:
            news.set_image(image)

    def perform_create(self, serializer):
        self.set_image_from_request(serializer.save())

    def perform_update(self, serializer):
        self.set_image_from_request(serializer.save())

Можно это все засунуть в сериализатор, и там переопределить нужные методы, но опять таки вам все равно нужно будет написать некоторую часть кода. Если кто-то сможет предложить более лучшую реализацию, так чтобы я ничего не перегружал, то я буду очень признателен.
Еще одним минусом является дублирование кода. Так как вьюхи по всем другим моделям у меня перегружают методы создания и обновления точно также, за мелкими отличиями.

На самом деле, я думаю что это можно оформить в какой-то универсальный миксин. Но я не смог разобраться как это правильно сделать. Если у кого-то есть идея — буду очень благодарен.


Плюсы


  1. Можете реализовать древовидную структуру хранения ваших изображений, что крайне удобно в организации, и также является довольно оптимизированным решением, чем если бы вы хранили 100500 файлов в одной папке.
  2. Гибкость. Если вам нужно хранить например картинки в отношении один ко многим, то достаточно создать промежуточную таблицу (например: picture_to_news), и также записывать ваши файлы туда куда вам нужно.
  3. Вы можете навешивать доп. логику на обработку ваших картинок: ресайзить, кропить etс.


Итог

Вывод утилиты tree

image

В целом мое решение, меня полностью устраивает, за исключением тех минусов, которые я привел выше. Если кто знает, как можно их избежать, буду рад послушать. Также, я думаю что это можно было бы оформить в качестве библиотеки, если добавить методы работы с изображением: ресайзы, кропы etc. И сделать валидацию изображения. В проекте этого нет. Но вот пример кода, как я это обычно делаю:


развернуть
        try:
            file = request.data['file']
            Image.open(file).verify()
            account.set_avatar(file)
            print(account.avatar.local_url)
            return Response(status=status.HTTP_200_OK)
         except Exception as e:
            return Response(data={"error": str(e)}, status=status.HTTP_400_BAD_REQUEST)

Не очень удобно, но если вынести эту логику в Picture, то я думаю что с этим проблем не будет.

Надеюсь эта статья была для вас интересна. Если у вас есть интересные мысли по поводу этого, оставляйте в комментариях, я обязательно их прочту. Спасибо за внимание.


Полезные ссылки

Ссылка проекта на GitHub
Статья о том как хранить пикчи на бэкенде в общем случае
Документация по ImageField
Краткий пост о частых ошибках при хранении файлов

© Habrahabr.ru