Djangoで、ファイルアップロード

2018-11-26 / PythonDjango

概要

今回はちょっとしたファイルアップローダーを作りながら、幾つかのファイルアップロード方法についてを説明していきます。ファイルアップローダーを例に説明していきますが、それに限らずに色々と応用ができます。Githubにソースコードを置いたので、試したい方はクローンしてください。

まず、メディアファイルを扱うための設定をしておきましょう。

settings.py

MEDIA_URL = '/media/'
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')

urls.py

from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
from django.urls import path, include

urlpatterns = [
    path('admin/', admin.site.urls),
    path('', include('app.urls')),
]

urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

単一ファイルのアップロード

1つのファイルをアップロードしたい場合の例です。

モデルを利用しない

ファイルアップロード機能が欲しいが、アップロードしたファイルのURLだけ教えてくれれば充分というケースがあります。
ファイルアップロード1

ファイルアップローダー以外だと、ブログの記事本文中に画像URLを埋め込みたい場合のバックエンドだとか、簡易的なお問い合わせページ(モデルとして管理していない)を作っていて、メールで内容と一緒にファイルのURLも受け取りたい場合にも応用できます。割と使い道がある方法です。

forms.pyです。

from django import forms
from django.core.files.storage import default_storage


class SingleUploadForm(forms.Form):
    file = forms.ImageField(label='画像ファイル')

    def save(self):
        upload_file = self.cleaned_data['file']
        file_name = default_storage.save(upload_file.name, upload_file)
        return default_storage.url(file_name)

これはあくまで、モデルフォームではなく通常のフォームです。save()は上書きした訳ではありませんforms.Formにはsave()はないです。

保存処理をするメソッドの名前として、そしてモデルフォームと似たような扱いができるようにsave()という名前にしているだけです。upload()とかそういう名前にしても大丈夫です。

save()はファイルの保存を行い、そのファイルURLのパスを返しています。/media/1_CIGg8bv.pngみたいな文字列を返します。ファイル名が被ったら、似たような名前を勝手につけてくれます。

    def save(self):
        upload_file = self.cleaned_data['file']
        file_name = default_storage.save(upload_file.name, upload_file)
        return default_storage.url(file_name)

ビューです。

from django.views import generic
from .forms import SingleUploadForm


class SingleUploadView(generic.FormView):
    form_class = SingleUploadForm
    template_name = 'app/upload.html'

    def form_valid(self, form):
        download_url = form.save()
        context = {
            'download_url': download_url,
            'form': form,
        }
        return self.render_to_response(context)

フォームのsave()でファイルURLのパスが帰るので、それを取得してテンプレートへ渡しています。

テンプレートは適当に作りました。upload.htmlは以下のようにしておきます。

    <form action="" method="POST" enctype="multipart/form-data">
        {{ form.as_p }}
        {% csrf_token %}
        <button type="submit">送信</button>
    </form>

    {% if download_url %}
        <a href="{{ download_url }}">{{ download_url }}</a>
    {% endif %}

enctype="multipart/form-data"を忘れないようにしましょう。

ファイルのフルなURLを返す必要がある場合は、ビューでフルパスを作る例テンプレートで作る例を参考にしてください。

モデルを利用する

アップロードされたファイルを一覧表示したり、更新したり、削除したり、そういった操作をしたくなったらモデルを利用します。もし使いやすいファイルアップローダーを作るならば、モデルを利用することになるでしょう。

今回は次のように、アップロードファイルの一覧を確認できるようにしていきます。 単一モデルあり

必要なのは、モデルにmodels.FileFieldmodels.ImageFieldを定義することです。

from django.db import models


class UploadFile(models.Model):
    """アップロードされたファイルを表すモデル"""
    file = models.ImageField('画像ファイル')  # これが重要

    def __str__(self):
        """ファイルのURLを返す"""
        return self.file.url

forms.py。モデルフォームを利用すると簡単です。これだけで、HTML上にファイルのアップロード欄が作成されます。

...
from .models import UploadFile


class SingleUploadModelForm(forms.ModelForm):

    class Meta:
        model = UploadFile
        fields = '__all__'

ビューです。アップロード処理のビューと、ファイルの一覧表示ビューを定義しました。

from django.urls import reverse_lazy
from django.views import generic
from .forms import SingleUploadForm, SingleUploadModelForm
from .models import UploadFile
...
...
class SingleUploadWithModelView(generic.CreateView):
    """ファイルモデルのアップロードビュー"""
    model = UploadFile
    form_class = SingleUploadModelForm
    template_name = 'app/upload.html'
    success_url = reverse_lazy('app:file_list')


class FileListView(generic.ListView):
    """アップロードされたファイルの一覧ページ"""
    model = UploadFile

アップロードページは、先ほど作ったupload.htmlを利用しています。

ファイルの一覧ページはuploadfile_list.htmlですが、次のようにファイルのURLだけ表示しています。

{% extends 'app/base.html' %}

{% block content %}
    <p><a href="{% url 'app:single_upload_with_model' %}">ファイルアップロードへ</a></p>
    <h1>ファイルの一覧</h1>
    {% for uploadfile in uploadfile_list %}
        <p><a href="{{ uploadfile.file.url }}">{{ uploadfile.file.url }}</a></p>
    {% endfor %}
{% endblock %}

複数ファイルのアップロード

ファイルを複数アップロードしたくなると、少し複雑になってきます。

フォームセット(モデルなし)

Djangoで、フォームセットを使うシリーズでは主にモデルフォームセット、インラインフォームセットを紹介しました。しかし、モデルに紐づかない通常のフォームセットというのも作ることができます。これを利用して、複数ファイルをアップロードし、アップロードしたファイルのURLをまとめて受け取る処理を作っていきます。

次のような動作です。
複数ファイルをアップロードし、アップロードしたファイルのURLをまとめて受け取る

まずですが、以下のようなフォームを定義しておきます。

class BaseUploadFormSet(forms.BaseFormSet):

    def save(self):
        # ['/media/1.png', '/media/2.png']のようなファイルのURLが入ったリストになる
        url_list = []

        # SingleUploadFormのsaveを順に呼び出し、ファイルURLを集める
        for form in self.forms:
            try:
                url = form.save()
            except KeyError:
                # ファイルがアップロードされていないフォームは、KeyErrorになるので、ここで無視する
                pass
            else:
                url_list.append(url)
        return url_list


UploadFormSet = forms.formset_factory(SingleUploadForm, formset=BaseUploadFormSet, extra=5)

上で説明した単一ファイルのアップロード モデルなしで、SingleUploadFormという、save()の呼び出しでアップロードファイルのURLを返す(モデルフォームじゃない)通常のフォームを作成しました。それを利用したフォームセットです。

通常、フォームセットオブジェクトはforms.formset_factory()で作るのですが、今回はURLのリストを一括で受け取れるメソッドを定義したかったので、BaseUploadFormSetというクラスを定義してsave()を作っています。送信されたファイルを保存し、それらのURLをリストで返します。

そのカスタマイズしたフォームセットクラスを、formset_factory()のformset引数に指定しています。モデルフォームセットやインラインフォームセットでも、今回のようにフォームセット自体の挙動をカスタマイズできます。

ビューです。download_url_listという、URLのリストをテンプレートへ渡します。

from .forms import SingleUploadForm, SingleUploadModelForm, UploadFormSet
...
...
class MultiUploadView(generic.FormView):
    form_class = UploadFormSet
    template_name = 'app/upload.html'

    def form_valid(self, form):
        download_url_list = form.save()
        context = {
            'download_url_list': download_url_list,
            'form': form,
        }
        return self.render_to_response(context)

upload.htmlを少し改良して、上のdownload_url_listを表示できるようにします。

{% extends 'app/base.html' %}

{% block content %}
    <form action="" method="POST" enctype="multipart/form-data">
        {{ form.as_p }}
        {% csrf_token %}
        <button type="submit">送信</button>
    </form>

    {% if download_url %}
        <a href="{{ download_url }}">{{ download_url }}</a>
    {% endif %}

    <!-- ここが増えた -->
    {% for download_url in download_url_list %}
        <p><a href="{{ download_url }}">{{ download_url }}</a></p>
    {% endfor %}
{% endblock %}

モデルフォームセット(モデルあり)

上でモデルを使ったファイルアップロードの例を紹介しましたが、それの一括アップロード版です。モデルフォームセットや場合によってはインラインフォームセットも良ければご覧ください。ファイルアップローダーなら欲しい機能ですね。

次のような感じです。
一括アップロード、モデルあり

forms.pyです。前に作ったモデルフォームを利用します。

UploadModelFormSet = forms.modelformset_factory(
    UploadFile, form=SingleUploadModelForm,
    extra=3
)

views.pyです。一括アップロードしたら、ファイルの一覧ページへ戻るようにします。クラスベースビューでは面倒な部分があるので、関数ビューにしました。

from django.shortcuts import render, redirect
from .forms import SingleUploadForm, SingleUploadModelForm, UploadFormSet, UploadModelFormSet
...
...
def multi_upload_with_model(request):
    formset = UploadModelFormSet(request.POST or None, files=request.FILES or None, queryset=UploadFile.objects.none())
    if request.method == 'POST' and formset.is_valid():
        formset.save()
        return redirect('app:file_list')

    context = {
        'form': formset
    }

    return render(request, 'app/upload.html', context)

queryset=UploadFile.objects.none()は、既にアップロード済みのファイルは表示しないという設定で、これをしないとアップロード済みファイルの更新フォームも表示されてしまいます。

input type="file" multiple(モデルなし)

スマホ環境では使えないこともあるのですが、こちらも紹介しておきます。UI的に、こちらの方が好きという方もいるかもしれません。

input type="file" multiple>な要素を使うと、Ctrlを押しながら複数のファイルを選択することができます。 複数ファイルを選択できる

まずフォームです。

class MultipleUploadForm(forms.Form):
    file = forms.ImageField(
        label='画像ファイル',
        widget=forms.ClearableFileInput(attrs={'multiple': True})
    )

    def save(self):
        url_list = []
        for upload_file in self.files.getlist('file'):
            file_name = default_storage.save(upload_file.name, upload_file)
            file_path = default_storage.url(file_name)
            url_list.append(file_path)
        return url_list

< input type="file">を、<input type="file" multiple>に変更する必要があります。そのため、widget引数を上書きしてそのように設定します。

保存処理ですが、cleaned_dataだと最後にアップロードされたファイルしか取得できないので、self.files.getlist()とします。ユーザーがアップロードしたファイルはrequest.FILESの中に管理され、フォームにはfiles=request.FILES のように渡されてきます。フォーム側からはself.filesとして、アップロードされたrequest.FILESにアクセスができるということです。

ビューは、複数ファイルアップロード(モデルなし)とほぼ同様です。

class InputMultiUploadView(generic.FormView):
    form_class = MultipleUploadForm
    template_name = 'app/upload.html'

    def form_valid(self, form):
        download_url_list = form.save()
        context = {
            'download_url_list': download_url_list,
            'form': form,
        }
        return self.render_to_response(context)

バリデーションですが、上の例ならばアップロードされたすべてのファイルに対してImageFieldとしてのバリデーションが行われます。安心です。

input type="file" multiple(モデルあり)

(途中。時間ができたら、専用のモデルフィールドとして作る)

この記事の関連記事

Djangoでフォームセットを使うシリーズ

2018-10-17 / PythonDjangoシリーズ・まとめ

- Djangoにはフォームセットという、複数のフォームを一括で扱うための機能があります。いくつか種類があり、様々な機能があるので、それらを紹介していきます。

Requestsを使ったファイルアップロード

2018-11-21 / PythonDjangoRequests

- PythonのHTTPライブラリとして有名なrequestsを使い、ファイルのアップロード処理をしてみます。

Djangoで、ファイルダウンロード

2018-11-28 / PythonDjango

- Djangoで、ファイルダウンロードの幾つかの方法や、ZIP化でのまとめてダウンロードを紹介します。

Djangoで、ブログアプリの本文に画像を挿入する方法まとめ

2019-05-14 / PythonDjango

- ブログアプリケーションを作成した際の、記事本文へ画像を差し込む方法についてよく質問が来ますので、それについてを説明していきます。これは記事をどうやって書いているかによって、方法が変わります。

コメント欄

記事にコメントする

名無し

こんばんは、別の場所でもコメントさせていただいたのですが、内容がかぶっていたのでこちらで一気にコメントさせていただきます。 私はDjangoでTwitterのようなSNSを作ろうと考えています。 その際、Twitterというツイート本文を保存するモデルを作り、TwitterFIleという子モデルにForeignKeyFIleFIeldを持たせ、本文投稿と同時に画像も投稿できるようにフォームセットを使い、さらにネイティブアプリに似せるために複数ファイルを一つのボタンから選択できるようにinput type="file" multipleも使い、さらには動画をツイートするときには画像は一枚も添付できないようにバリデーションを行いたいと考えています。 ただ、あまりにも複雑で実装できたとしても自分の能力ではメンテナンス不可能なコードになってしまいそうです。 そこでTwitterモデル自体にblank=True, null=TrueImageFieldをいくつも定義するというかなり汚い方法で実装を考えているのですが、このような実装についてどう思われますか? なりとさんの意見をぜひお聞きしたいと思います。

コメントに返信する

なりと

そこまで悪くはないです。

ファイルを十個とか添付できるようにしたいなら話は別ですが、数個程度の添付ならばTwitterモデルに直接書いてもそんなに問題はないと思います。

mori

ImageFieldを使用したフォームだと、アップロードする画像を選択を行っても フォーム画面に、選択した画像のプレビューが表示されないかと思います。 フォームでImageFieldを扱う場合に、選択した画像のプレビューを行う実装方法等について わかりますでしょうか。

コメントに返信する

なりと

JavaScriptを使って実装するようにしてください。

こちらなど参考になると思います。

名無し

初めまして、いつも参考にさせていただいています。 前ブログの「Django、クラスベースビューの機能を組み合わせる」にてDetailViewとCreateViewの組み合わせについての質問なのですが フォームにファイルアップロードの欄があった場合アップロードをしないで送信をすると、ファイルが選択されていない状態なので データベースには空欄が登録されてしまいます。 前回にアップロードして登録された値をそのまま引き継ぎたいのですが、何か方法はありますでしょうか? 参考(というかほぼそのまま)にしたビューは以下です。

class DetailAndCreate(ModelFormMixin, generic.DetailView):
    model = Post
    form_class = CommentCreateForm
    template_name = 'app/post_detail_and_comment_create.html'

    def form_valid(self, form):
        post_pk = self.kwargs['pk']
        comment = form.save(commit=False)
        comment.post = get_object_or_404(Post, pk=post_pk)
        comment.save()
        return redirect('app:detail_and_create', pk=post_pk)

    def post(self, request, *args, **kwargs):
        form = self.get_form()
        if form.is_valid():
            return self.form_valid(form)
        else:
            self.object = self.get_object()
            return self.form_invalid(form)

コメントに返信する

なりと

状況がよくわからないので、現状のソースコードかそれを再現したDjangoプロジェクトを送ってください。