[Из песочницы] Хранение изображений с помощью 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 указывает название папки, в которую нужно загрузить вашу пикчу. И получается, что в рантайме мы никак не сможем повлиять на место куда будет загружено ваше изображение. Выходит что для одной модели, все изображения будут складываться в одну папку. Беспорядок и непорядок какой-то в общем.
Основная идея
Создать свой класс Picture, в котором также будет использоваться ImageField, но upload_to будет реализовано в виде функции, которая будет возвращать нужное имя папки. Соответственно во всех моделях, где вам нужна картинка: использовать класс Picture. Также нужно придумать алгоритм построения дерева папок, так как мой пример, может не подойти конкретно вам. Для себя я определил, что у изображения будет владелец — компания, аккаунт etc. Будет тип изображения, обычно по названию модели, с которым сейчас работает. Доп. параметр, если мне для одной сущности, нужно хранить больше чем одно изображение или у владельца может быть несколько дочерних сущностей, которые должны в себе хранить картиночки.
Пример реализации на простом проекте
Схема БД будет всего из 4 моделей:
Остановимся на моделе 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 — это вспомогательный класс, который просто возвращает полный путь папки, в которую нужно поместить пикчу.
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())
Можно это все засунуть в сериализатор, и там переопределить нужные методы, но опять таки вам все равно нужно будет написать некоторую часть кода. Если кто-то сможет предложить более лучшую реализацию, так чтобы я ничего не перегружал, то я буду очень признателен.
Еще одним минусом является дублирование кода. Так как вьюхи по всем другим моделям у меня перегружают методы создания и обновления точно также, за мелкими отличиями.
На самом деле, я думаю что это можно оформить в какой-то универсальный миксин. Но я не смог разобраться как это правильно сделать. Если у кого-то есть идея — буду очень благодарен.
Плюсы
- Можете реализовать древовидную структуру хранения ваших изображений, что крайне удобно в организации, и также является довольно оптимизированным решением, чем если бы вы хранили 100500 файлов в одной папке.
- Гибкость. Если вам нужно хранить например картинки в отношении один ко многим, то достаточно создать промежуточную таблицу (например: picture_to_news), и также записывать ваши файлы туда куда вам нужно.
- Вы можете навешивать доп. логику на обработку ваших картинок: ресайзить, кропить etс.
Итог
Вывод утилиты tree
В целом мое решение, меня полностью устраивает, за исключением тех минусов, которые я привел выше. Если кто знает, как можно их избежать, буду рад послушать. Также, я думаю что это можно было бы оформить в качестве библиотеки, если добавить методы работы с изображением: ресайзы, кропы 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
Краткий пост о частых ошибках при хранении файлов