Эта проблема:
В вашем приложении Django у вас есть HTML-форма с несколькими полями для заполнения пользователями, включая загрузку файлов / изображений. При использовании форм Django пользовательский опыт выглядит примерно так:
- Пользователь заполняет форму, включая выбор файлов для загрузки
- Пользователь отправляет форму.
- Ой! Ошибка. Форма повторно отображается с полезными сообщениями.
- Пользователь исправляет ошибки, повторно отправляет форму.
- Ой! Еще одна ошибка. «Пожалуйста, загрузите файл». Когда форма была повторно отрисована, выбор файлов был очищен, и они этого не заметили.
Это проблема. Файлы, выбранные для загрузки, не сохраняются на сервере в случае сбоя проверки формы. Какое решение? Ну, во-первых, вы можете проверить отправку в JavaScript, прежде чем отправлять ее на сервер, но если ваша форма полагается на запросы к базе данных или другую внутреннюю логику, вам не повезло. Кроме того, даже несмотря на то, что всегда рекомендуется выполнять проверку в браузере, дублирование сложного кода проверки может легко привести к расхождению кода, поэтому лучше всего сделать проверку формы в браузере простой, например проверку наличия / отсутствия. Что делать? Разочаровывающий опыт как для пользователя, так и для разработчика.
Решение: обрабатывать загрузку файлов асинхронно
Немедленно загружая файлы, которые выбирает пользователь, мы можем сохранить эту информацию в форме и повторно отобразить ее вместе с остальными очищенными данными формы в случае ошибки. Пользователь также получает немедленную обратную связь в виде миниатюры изображения или ошибки.
Прежде чем мы рассмотрим код, давайте просто рассмотрим новый поток данных.
Пользователь заполняет форму как обычно, но когда событие change запускается при вводе файла:
- Файл немедленно асинхронно отправляется на сервер, который отвечает объектом JSON, который включает путь к изображению и идентификатор базы данных.
- Миниатюра или значок файла помещается на страницу в качестве отзыва (или отображается сообщение об ошибке)
- В скрытое поле формы добавляется новая запись с идентификатором загруженного файла.
- Когда форма отправляется, мы используем набор запросов, сгенерированный скрытым полем, для создания одной или нескольких записей таблицы переходов, соединяя наш основной экземпляр модели со связанным изображением или файлом.
- Если в форме есть какие-либо ошибки, когда она повторно отображается для пользователя, мы можем ссылаться на скрытое поле, вытаскивать информацию об изображении или файле и снова создавать эскиз или значок файла. Загруженное изображение остается!
Звучит неплохо? Напишем код. Во-первых, вот как выглядела наша модель раньше:
class Survey(models.Model): name = models.CharField(max_length=255) email = models.EmailField() zipcode = models.CharField(max_length=5) comments = models.TextField() image = models.ImageField(upload_to='survey_images/')
Нам нужно немного изменить ситуацию: поместить изображение в отдельный класс и создать модель прыжка, чтобы связать их вместе.
class RelatedImage(models.Model):
image = models.ImageField(upload_to='survey_images/')
def __unicode__(self):
return self.image.url
class Survey(models.Model):
name = models.CharField(max_length=255)
email = models.EmailField()
zipcode = models.CharField(max_length=5)
comments = models.TextField()
class SurveyImage(models.Model):
image = models.ForeignKey(RelatedImage)
survey = models.ForeignKey(Survey)
Экземпляр RelatedImage будет создан сразу после асинхронного выбора / загрузки файла, а экземпляр SurveyImage будет создан при сохранении формы. Давайте посмотрим на форму. Вот как выглядела наша форма раньше:
class SurveyForm(forms.ModelForm):
class Meta:
fields = ('name', 'email', 'zipcode', 'comments', 'image')
Теперь нам нужно вытащить image и добавить новое поле в нашу форму. Мы будем использовать ModelChoiceField (см. Документацию здесь). Использование этого поля удобно, потому что если мы попытаемся сослаться на несуществующее изображение, Django поймает это за нас. Конечно, каждое решение представляет собой новый набор потенциальных проблем, и это не исключение - мы обсудим их позже. А пока вот наша новая форма:
class SurveyForm(forms.ModelForm):
image = forms.ModelChoiceField(
queryset=RelatedImage.objects.all(),
required=False)
class Meta:
fields = ('name', 'email', 'zipcode', 'comments')
def __init__(self, *args, **kwargs):
super(SurveyForm, self).__init__(*args, **kwargs)
if self.instance.pk:
self.fields['image'].initial = models.RelatedImage.objects
.get(surveyimage__survey=self.instance)
.values_list('id', flat=True)
def save(self, commit=True):
super(SurveyForm, self).save(commit)
image = self.cleaned_data.pop('image', None)
if image:
self.instance.surveyimage.delete()
models.SurveyImage.objects
.create(survey=self.instance, image=image)
Помимо создания ModelChoiceField, мы отменяем методы __init__ и save ModelForm. Переопределение __init__ для установки значения initial поля изображения сделает изображение доступным в режиме редактирования. Мы добавляем логику для создания записи в SurveyImage таблице переходов внутри метода save.
Подключение асинхронной загрузки изображений
Нам понадобится немного JavaScript и помощь Django Rest Framework, чтобы это исправить. Вот список задач, которые нам еще предстоит выполнить:
- Сделайте сериализатор для создания
RelatedImageобъектов - Добавьте набор представлений и маршрутизацию URL-адресов, чтобы предоставить конечные точки ajax для нашего JavaScript.
- Напишите JavaScript, чтобы собрать все воедино.
Части 1 и 2 просты:
# serializers.py
from rest_framework import serializers
from .models import RelatedImage
class RelatedImageSerializer(serializers.ModelSerializer):
class Meta:
model = RelatedImage
fields = ('id', 'image')
# ajax_views.py
from rest_framework import viewsets
from .models import RelatedImage
from .serializers import RelatedImageSerializer
class RelatedImageAJAXView(viewsets.ModelViewSet):
serializer_class = RelatedImageSerializer
queryset = RelatedImage.objects.all()
# modify urls.py
from rest_framework.routers import DefaultRouter
from .ajax_views import RelatedImageAJAXView
ajax_router = DefaultRouter()
ajax_router.register(r'', RelatedImageAJAXView)
urlpatterns = [
...
url(r'^related_images/', include(ajax_router.urls)),
...
]
Наборы представлений Rest Framework удобны, потому что они заботятся обо всех действиях CRUD за вас. Например, если вам нужно добавить поведение к действию list, вы можете переопределить этот метод, но для этого (и большинства) случаев вам не нужно ничего делать, кроме предоставления класса сериализатора и набора запросов. Rest Framework предоставляет маршрутизатор, который обрабатывает для вас маршрутизацию к viewset, поэтому вам не нужны конечные точки URL для каждого действия CRUD. Потрясающие!
Наконец, напишите шаблон и JavaScript
Вот набросок того, как может выглядеть ваш шаблон:
...
<form>
{{ csrf_token }}
{% for field in form %}
{% if field.name == 'image' %}
<div class="hidden">
{{ form.image }}
<input type="file" id="hidden_image_upload" />
</div>
<div id="upload_image_button">Upload Image</div>
<div id="upload_image_errors"></div>
<div id="image_preview"></div>
{% else %}
{{ field }}
{% endif %}
{% endfor %}
</form>
...
{# don't forget to pass csrf token to javascript! #}
<script type="application/javascript">
var csrf_token="{{ csrf_token }}";
</script>
По мере того, как мы перебираем поля формы, мы достигаем поля image и визуализируем его внутри скрытого div вместе с вводом файла. Мы также добавляем кнопку, которую мы подключим к элементу ввода, и блок предварительного просмотра. Давайте быстро займемся JavaScript. Потерпите меня:
let uploadImageButton = document
.getElementById('upload_image_button');
let uploadImageErrors = document
.getElementById('upload_image_errors');
let imageSelect = document.getElementById('id_image');
let fileInput = document
.getElementById('hidden_image_upload');
let imagePreview = document.getElementById('image_preview');
let addThumbnail = (data) => {
if (data.error) {
uploadImageErrors.innerHTML = data.error;
} else {
// if the 'from_initial_data' flag is not flipped, we need
// to add it to the hidden select option.
if (!data.from_initial_data) {
imageSelect.innerHTML = `
<option value=${data.id}" selected>
${data.image}
</option>`;
}
uploadImageErrors.innerHTML = '';
imagePreview.innerHTML = `
<div class="image_thumbnail"
style="background-image: url(${data.image})">
<div id="image_delete">[×]</div>
</div>`;
//stash this in services.js or elsewhere
let handleFileUpload = (e, callback) => {
let myImage = e.target.files[0];
let formData = new FormData();
formData.append("image", myImage);
let myHeaders = new Headers();
myHeaders.append('X-CSRFToken', window.csrf_token);
let myInit = {method: 'POST', credentials: 'same-origin',
headers: myHeaders, body: formData}
let request = new Request('/related_images/', myInit);
fetch(request)
.then(response => response.json())
.then(json => {
if (typeof(json['image']) == 'object') {
callback({'error': json['image'][0]});
} else {
callback(json);
}
});
};
if (uploadImageButton) {
fileInput.addEventListener('change',
(e) => handleFileUpload(e, addThumbnail));
upload_image_button.addEventListener('click',
() => fileInput.click());
let selectedImage = [...imageSelect.selectedOptions];
if (selectedImage.length > 0) {
addThumbnail({id: selectedImage[0].value,
from_initial_data: true,
image: selectedImage[0].innerText})
}
}
Это много! Ну, не совсем, но намного больше, чем у нас было изначально. Обратите внимание, что я не включил никаких действий для удаления миниатюры и отказа от изображения. Для этого вам нужно добавить прослушиватель событий в #image_delete, чтобы удалить миниатюру и отменить (или просто удалить полностью) параметр в скрытом выборе #id_photo.
Заключительные мысли и предостережения
Ранее я упоминал о некоторых проблемах, связанных с этим шаблоном. В зависимости от вашего варианта использования вы захотите адаптировать свое решение в соответствии со своими потребностями. Вот пара вопросов, которые вы, возможно, захотите рассмотреть.
Экземпляры RelatedImage никому не принадлежат, поэтому они принадлежат всем.
Вы можете отредактировать скрытый выбор и добавить идентификатор для фотографии, которую вы не загружали. Это может не иметь большого значения, если фотографии будут общедоступными.
Некоторые способы обойти это: вы можете прикрепить поле внешнего ключа к экземпляру RelatedImage, чтобы пользователи могли видеть только свои собственные изображения, вы можете создать группы разрешений для пользователей, чтобы они могли просматривать изображения только в своей собственной группе (разрешить пользователям работать совместно в форме), или, наконец, вы можете ограничить набор запросов изображениями, которые не принадлежат экземпляру SurveyImage (т.е. форма никогда не была отправлена / заполнена, что создает эту ссылку). Последняя проблема, в зависимости от вашей пользовательской базы, по-прежнему может привести к довольно большому пулу изображений, в котором нужно копаться. Вы можете добавить поле created_at в свой RelatedImage, а затем установить задание cron для удаления изображений старше, скажем, часа. Старый.
Список скрытых вариантов выбора может стать большим
Документация Django отмечает это, сообщая нам … виджет по умолчанию для ModelChoiceField становится непрактичным при увеличении количества записей. Вам не следует использовать его для более чем 100 предметов . Вы можете справиться с этим путем условной визуализации поля: если ваша форма имеет экземпляр (то есть, если мы редактируем, а не создаем), визуализируйте поле как обычно, в противном случае вручную визуализируйте пустое поле select.
Вероятно, вам следует создать эскиз
Вероятно, неплохо было бы создать эскиз и отказаться от RelatedImage метода save вашей модели. Таким образом, вы не уменьшаете изображение размером 3000x2000 пикселей, чтобы оно поместилось в миниатюру, при рендеринге формы.
Может быть, вообще отказаться от использования ModelChoiceField?
Вы можете отказаться от ModelChoiceField и просто создать дополнительный CharField и указать атрибут choices (пустой, если создается новый экземпляр, в противном случае заполняется существующими данными). Изначально этот метод кажется заманчивым, но мне нравится дополнительная проверка, которая поставляется с Django. YMMV.
Я надеюсь, что это даст вам хорошую отправную точку для изучения лучшего пользовательского опыта с вашими формами Django. Удачи!