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

Twitterでシェア FaceBookでシェア はてなブックマークでシェア

Python - Django
2018年11月29日9:54に更新(約14日前)
2018年11月28日1:21に作成(約15日前)

概要

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

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

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)

以下のような、シンプルなモデルがあり...

from django.db import models


class UploadFile(models.Model):
    file = models.FileField('ファイル')

    def __str__(self):
        return self.file.url

一覧表示のためのビューがあり...

from django.views import generic
from .models import UploadFile


class UploadList(generic.ListView):
    model = UploadFile

シンプルなuploadfile_list.htmlがあります。

    {% for uploadfile in uploadfile_list %}
        <a href="{{ uploadfile.file.url }}">{{ uploadfile }}</a>
        <hr>
    {% endfor %}

FileField、又はImageFieldurl属性には、そのファイルにアクセスするためのURLが入っています。<img>要素のsrc属性に指定したり、<a>のhref属性に指定することが多いですね。

ここまでの見た目は以下のような感じです。
ファイルの一覧とリンクが表示されている

単一ファイルのダウンロード

現状、リンクをクリックで幾つかのファイルはダウンロードできます。しかし、画像やテキスト等のファイルはブラウザ上で開いてしまいます。リンクを右クリックで保存はできるのですが、ちょっと面倒くさいです。ダウンロードを強制させたいかもしれません。

a要素のdonwload属性を使う

最も手軽な方法です。

<a href="{{ uploadfile.file.url }}" download="{{ uploadfile.file.name }}">{{ uploadfile }}</a>

downloadという属性を追加しました。{{ uploadfile.file.name }}はファイル名になります。

以前はブラウザによって動作するしないがあったのですが、最近は動作するようになってきました。iOS Safariでサポートされれば、このdownload属性も安定しそうですね。

ダウンロード用ビューの作成

a要素のdownload属性はある程度動作しますが、IEでは基本的に動作しません。そういった場合、一番確実なのはHttpResponseにContent-Dispositionというヘッダーを追加することです。Djangoのビューがやっているのは結局のところHttpResponseの作成なので、これはビュー側で対応できます。

まず、uploadfile_list.htmlを修正します。

    {% for uploadfile in uploadfile_list %}
        <a href="{% url 'app:download' uploadfile.pk %}">{{ uploadfile }}</a>
        <hr>
    {% endfor %}

href="{% url 'app:download' uploadfile.pk %}"として、リンククリックでダウンロード用のビューを呼び出すようにしています。

勿論urls.pyにも定義しておきます。

from django.urls import path
from . import views

app_name = 'app'

urlpatterns = [
    path('', views.UploadList.as_view(), name='upload_list'),
    path('download/<int:pk>/', views.download, name='download')  # ダウンロード用ビュー
]

そして、ダウンロードビューです。

def download(request, pk):
    upload_file = get_object_or_404(UploadFile, pk=pk)
    file = upload_file.file  # ファイル本体
    name = file.name  # ファイル名

    # ファイル名からmimetypeを推測。拡張子がないファイル等は、application/octet-stream
    response = HttpResponse(content_type=mimetypes.guess_type(name)[0] or 'application/octet-stream')

    # Content-Dispositionでダウンロードの強制
    response['Content-Disposition'] = f'attachment; filename="{name}"'

    # HttpResponseに、ファイルの内容を書き込む
    shutil.copyfileobj(file, response)

    return response

ビューはHttpResponse、又はその派生クラスを返す必要があります。普段はrenderredirect、クラスベースビューの内部で作成されるのであまり意識することはありませんが、今回のようなちょっと凝ったことをすると触る必要が出てきます。

HttpResponse(content_type='text/html')のようにcontent_typeを指定する必要があるのですが、それはファイルの種類によって変わります。htmlからもしれないし、cssかもしれないし、もしかしたらexeかもしれません。

そこで、ファイル名を元に判断してくれるmimetypes.guess_type()を使っています。(text/html, gzip)といったタプルを返します。mimetype, 符号化方式ですね。今回はmimetypeの部分だけでいいので[0]としています。

拡張子がなかったり珍しい拡張子だと、mimetypeの部分がNoneになります。そのようなときのために、or application/octet-streamとしています。application/octet-streamはmimetypeがよくわからない場合に使います。

response['Content-Disposition'] = f'attachment; filename="{name}"'として、Content-Dispositionヘッダーとファイル名を設定します。

HttpResponseオブジェクトもFileField・ImageFieldもファイルライクなオブジェクトです。このようなファイルライクなオブジェクト同士でのコピーは、shutil.copyfileobj()が便利です。

CSVやJSON、PDF、ZIP等をDjango側で作成するという機会はたまにあるのですが、そういった場合も今回と似たような感じの処理になります。

Webサーバー側で対応する

Djangoアプリケーションを実際に運用すると、何かしらのWebサーバーの背後で動かすのが一般的になります。

上ではContent-Dispositionヘッダーの追加をDjango側で行いましたが、もちろんApacheやNginxといったWebサーバー側で対応することもできます。下で紹介するZIP化などの凝ったことは難しくなりますが、パフォーマンス的には良くなります。

あるディレクトリの中のファイルだけダウンロード強制だとか、ある拡張子の場合はダウンロード強制といったこともできます。

FileFieldやImageFieldはupload_to引数で動的にファイルの保存場所を変えれますので、あるFileFieldはダウンロードを強制させる設定にしたディレクトリに置く、等も場合によっては有用かもしれません。

複数ファイルのダウンロード(ZIP)

さきほど作ったビューを使えば、リンククリックでファイルがダウンロードできます。いちいちページ遷移したりもないので、複数ファイルのダウンロードも実質できています。クリックしまくるだけです。

今回はもう少し進み、ユーザーがチェックしたファイル達をZIPで圧縮し、まとめてダウンロードさせる処理をしてみます。

一覧画面にチェックボックスがあり...
一覧画面にチェックボックスがついた

ボタンを押すと、ZIPファイルがダウンロードされます。
ZIPがダウンロードされる

中身も大丈夫そうです。
解凍するとちゃんと動く

まず、uploadfile_list.htmlです。

    <form action="{% url 'app:download_zip' %}" method="POST">
        {% for uploadfile in uploadfile_list %}
            <a href="{% url 'app:download' uploadfile.pk %}">{{ uploadfile }}</a>
            <input type="checkbox" name="zip" value="{{ uploadfile.pk }}">
            <hr>
        {% endfor %}
        {% csrf_token %}
        <button type="submit">ZIPダウンロード</button>
    </form>

各ファイルの横にチェックボックスを置きました。チェックしたファイルのpkをビューで受け取り、それらのファイルをもとにZIPを作るという流れです。

Djangoで、選択したデータを一括削除でも、このようなチェックボックスを用いた例を紹介しました。

urls.pyで、ビューと紐づけておきます。

from django.urls import path
from . import views

app_name = 'app'

urlpatterns = [
    path('', views.UploadList.as_view(), name='upload_list'),
    path('download/<int:pk>/', views.download, name='download'),
    path('download/zip/', views.download_zip, name='download_zip')  # 増えた
]

そしてビューです。

import mimetypes
import shutil
import zipfile  # 増えた
from django.http import HttpResponse
from django.shortcuts import get_object_or_404
from django.views import generic
from .models import UploadFile
...
...
def download_zip(request):
    file_pks = request.POST.getlist('zip')  # <input type="checkbox" name="zip"のnameに対応
    upload_files = UploadFile.objects.filter(pk__in=file_pks)

    response = HttpResponse(content_type='application/zip')
    file_zip = zipfile.ZipFile(response, 'w')
    for upload_file in upload_files:
        file_zip.writestr(upload_file.file.name, upload_file.file.read())

    # Content-Dispositionでダウンロードの強制
    response['Content-Disposition'] = 'attachment; filename="files.zip"'

    return response

request.POST.getlist('zip')でチェックされたファイルのpkを取得し、filter(pk__in=[1, 2. 3, 4])のようにして各ファイルのモデルインスタンスを取得します。

zip.ZipFile()は引数にファイルライクなオブジェクトを渡すことができ、それに対して1つ1つのファイルを書き込んでいきます。HttpResponseオブジェクトはファイルライクなオブジェクトなので、zip.ZipFile()に渡せます。

後は、各モデルインスタンスを取り出してwritestr()で書き込んでいくだけ、イージーですね。

Twitterでシェア FaceBookでシェア はてなブックマークでシェア

記事にコメントする

名無し
2018年11月29日22:27にコメント(約14日前)

いつも拝見しております。ファイルのアップロード、ダウンロードに関連して質問です。

業務系のアプリケーションで、エクセルをテンプレートとしてアップロードしておき、何らかの条件で集計した値をアップロードしておいたエクセルに書き込んでダウンロードさせる、という機能を作りたい希望があるのですが、難易度はかなり高いでしょうか。質問者のスキルによるとは思いますが、可能な範囲でお教えいただけると嬉しいです。

コメントに返信する

なりと
2018年11月29日23:54に返信(約14日前)

難易度はそこまで高くないように思います。

エクセルを扱うためのライブラリは幾つかあるのですが、openpyxlを使った例ですと以下のような感じで書けます。

from openpyxl import Workbook  # pip install openpyxl
...
...
def download_xlsx(request):
    wb = Workbook()  # ワークブックの作成
    ws = wb.active
    ws['A1'] = 'Hello'  # A1に書き込む
    response = HttpResponse(content_type='application/vnd.ms-excel')  # エクセルファイルを表す
    response['Content-Disposition'] = 'attachment; filename="test.xlsx"'
    wb.save(response)  # ワークブックの内容をレスポンスへ書き込む
    return response

予めアップロードしていたエクセルファイルを読み込んで編集することもできます。

from openpyxl import load_workbook
...
def download_xlsx(request):
    wb = load_workbook('test.xlsx')  # 読み込み
...
名無し
2018年11月30日8:51に返信(約13日前)

ありがとうございます。 ちょっとチャレンジしてみようと思います。 テンプレートとなるエクセルファイルの置き場所は、 MEDIAを使うのが通常でしょうか。

なりと
2018年11月30日9:29に返信(約13日前)

それでもいいのですが、settings.pyあたりにファイルのパスを直接定義するのもシンプルだと思います。

manage.pyと同じ改装にでもエクセルファイルを置きまして、settings.pyに以下のように定義しておきます。

EXCEL_PATH = os.path.join(BASE_DIR, 'テンプレート.xlsx')

ビューでは以下のような感じです。

from django.conf import settings
...
...
wb = load_workbook(settings.EXCEL_PATH)
名無し
2018年12月1日21:34に返信(約12日前)

ありがとうございます。

EXCEL_PATHを定義して試してみているのですが、もう一つ質問がございます。。 manage.pyと同じ階層に、'excel'というフォルダを置いて、その中に保存することはできたのですが、その中に、システムにログインした時に選択した会社のフォルダに分けて、保存したいと思いました。

システムにログインするときにはrequest.sessionに会社コードを保管しているので、それを、●●●●会社でログインした時には'/excel/●●●●/'、××××会社でログインした時には'/excel/××××/'のように分けて保存したいと思ったのですが、なかなかうまくいっていません。日付に分けて保存する方法はnaritoさんのブログやdjangoの公式サイトで見つかったのですが、何か方法ありますでしょうか。

なりと
2018年12月3日3:25に返信(約10日前)

例えばモデルを使ってファイルを保存するならば、以下のような感じで可能です。

# CreateViewのform_validを上書き
    def form_valid(self, form):
        upload_file = form.save(commit=False)
        upload_file._dir = self.request.session['会社コードを取得するキー']
        upload_file.save()
        # 以降は普通にリダイレクト等

モデルのupload_toに指定する関数を、以下のようにしておきます。

def get_upload_path(instance, filename):
    if hasattr(instance, '_dir'):
        return os.path.join(instance._dir, filename)
    else:
        return os.path.join('others', filename)

upload_toで呼び出される関数にはrequestオブジェクトが渡されないので、関数からはセッションにアクセスできません。

そういった場合のちょっとしたハックとして、upload_file.hogehoge =のようにビュー側でモデルインスタンス自体に何らかの属性を設定しておきます。uplaod_toのinstance引数にはそのモデルインスタンスが渡されるので、設定した値が取得できます。

名無し
2018年12月3日8:25に返信(約10日前)

ありがとうございます! イメージ通りの事が実現できました!!

form_validの中で色々試行錯誤していたので、あたりとしては間違いではなかったかもしれないと思いましたが、教えていただいた通り、get_upload_toにrequestを渡す方法を考えていた李、まだまだ分からないことだらけだということがわかりました。。

お忙しい中ありがとうございました。