【Django備忘録】Django+jQueryでプログレスバーを表示しながらファイルをアップロードする

# はじめに
Django備忘録シリーズ 第2弾です。以前の記事は以下をご参照ください。
1. [【Django備忘録】django-environを使って機密情報を別ファイルで管理する](https://qiita.com/sho-gun/items/3c6db701fe326bee5ca4)

今回は、Django+jQueryで、プログレスバーを表示しながらファイルをアップロードする方法について書いていきます。
Djangoで作成したWebページにおいて、任意のファイルをPOSTする際にアップロード進捗をページに表示し、アップロード完了後は別のページに遷移するようなものを想定しています。

![スクリーンショット 2024-01-24 23.02.18.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3617690/bec35237-08a5-5765-d4a5-880ddf7aba50.png)

本記事は、Djangoアプリの基本的な実装方法は既に知っていることを前提としています。

ここで紹介する実装は[Github](https://github.com/sho-gun/django-samples)で公開しています。以下、ディレクトリ構成は公開しているリポジトリに準拠するものとします。

https://github.com/sho-gun/django-samples


# 全体像
編集するファイルとその役割は以下の通りです。

|ファイルパス                                             |役割                            |
|-------------------------------------------------------|-------------------------------|
|sample_project/file_upload/views.py                    |ページの表示                     |
|sample_project/file_upload/forms.py                    |ファイルアップロードフォームの設定   |
|sample_project/file_upload/templates/base.html         |jQueryやBootstrapの設定         |
|sample_project/file_upload/templates/upload_page.html  |プログレスバー付きアップロードページ |
|sample_project/file_upload/templates/success_page.html |アップロード成功ページ             |
|sample_project/file_upload/static/upload.js            |プログレスバーの描画               |


# 詳細

```python:sample_project/file_upload/views.py
from django.views import generic
from django.http import HttpResponse
from django.urls import reverse_lazy
from file_upload.forms import FileUploadForm


class FileUploadSampleView(generic.FormView):
    template_name = 'upload_page.html'
    form_class = FileUploadForm
    success_url = reverse_lazy('file_upload:success_page')

    def form_valid(self, form):
        """
        正常なファイルがアップロードされたときの処理
        """
        # ファイルを保存
        file = form.cleaned_data['file']
        with open(f'{file.name}', 'wb+') as destination:
            if file.multiple_chunks():
                for chunk in file.chunks():
                    destination.write(chunk)
            else:
                destination.write(file.read())

        # AjaxによるPOSTの場合はリダイレクト先のURLをのものを返す
        if self.__is_ajax():
            return HttpResponse(self.success_url)

        return super().form_valid(form)


    def form_invalid(self, form):
        """
        不正なファイルがアップロードされたときの処理
        例：0バイトのファイル
        """
        # AjaxによるPOSTの場合はリダイレクト先のURLをのものを返す
        if self.__is_ajax():
            return HttpResponse(reverse_lazy('file_upload:file_upload_sample'))

        return super().form_invalid(form)


    def __is_ajax(self):
        """
        リクエストがAjaxを用いて送信されていたらTrue
        """
        return self.request.headers.get('x-requested-with') == 'XMLHttpRequest'


class SuccessView(generic.TemplateView):
    template_name = 'success_page.html'
```

```python:sample_project/file_upload/forms.py
from django import forms

class FileUploadForm(forms.Form):
    file = forms.FileField()
```

```html:sample_project/file_upload/templates/base.html
<!DOCTYPE html>
<html>

  <head>
    <meta charset="utf-8">
    <meta content="width=device-width, initial-scale=1.0" name="viewport">

    <!-- Google Fonts -->
    <link href="https://fonts.gstatic.com" rel="preconnect">
    <link href="https://fonts.googleapis.com/css?family=Open+Sans:300,300i,400,400i,600,600i,700,700i|Nunito:300,300i,400,400i,600,600i,700,700i|Poppins:300,300i,400,400i,500,500i,600,600i,700,700i" rel="stylesheet">

    <!-- Bootstrap -->
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">

    <!-- JQuery -->
    <script src="https://code.jquery.com/jquery-3.2.1.min.js" integrity="sha256-hwg4gsxgFZhOsEEamdOYGBf13FyQuiTwlAQgxVSNgt4=" crossorigin="anonymous"></script>
  </head>

  <body>
    <main class="container">
      {% block content %}
      {% endblock %}
    </main>
  </body>

</html>
```

```html:sample_project/file_upload/templates/upload_page.html
{% extends "base.html" %}
{% load static %}

{% block content %}
  <script src="{% static 'upload.js' %}"></script>

  <form id="upload_form" action="" method="post" enctype="multipart/form-data">

    <!-- progress bar -->
    <div class="container my-3">
      <div class="progress" id="progress-container" hidden>
        <div class="progress-bar progress-bar-striped progress-bar-animated" id="progressbar" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" style="width: 0%"></div>
      </div>
    </div>

    <!-- FileUploadForm -->
    <div class="container my-3">
      {{ form.non_field_errors }}
      {% for field in form.hidden_fields %}
        {{ field }}
      {% endfor %}

      {% for field in form.visible_fields %}
        {{ field }}
        {{ field.errors }}
      {% endfor %}
    </div>

    {% csrf_token %}

    <div class="container my-3">
      <button id="submit-btn" type="submit" class="btn btn-primary">アップロード</button>
    </div>

  </form>
{% endblock %}
```

```html:sample_project/file_upload/templates/success_page.html
{% extends "base.html" %}
{% load static %}

{% block content %}
<div class="container my-3">
  <p>アップロードが完了しました</p>
  <a href="{% url 'file_upload:file_upload_sample' %}">戻る</a>
</div>
{% endblock %}
```

```javascript:sample_project/file_upload/static/upload.js
$(() => {
  $('#submit-btn').on('click', () => {
    // multipart/form-dataでファイルをアップロードするための準備
    let myform = new FormData();
    myform.append('file', $('#upload_form').find('[name="file"]').prop('files')[0]);

    // アップロードボタンの無効化とプログレスバーの表示
    show_progress();
    disable_button();

    // Ajaxでアップロード
    $.ajax({
      type: 'POST',
      processData: false,
      contentType: false,
      headers: {
        'X-CSRFToken': $('input[name="csrfmiddlewaretoken"]').val()
      },
      data: myform,
      xhr : function(){
        // プログレスバーの更新
        var XHR = $.ajaxSettings.xhr();
        if(XHR.upload){
            XHR.upload.addEventListener('progress',function(e){
                var progress = parseInt(e.loaded/e.total*100);
                $('#progressbar').prop('aria-valuenow', progress);
                $('#progressbar').css('width', progress + '%');
            }, false);
        }
        return XHR;
      },
    })
    .done(data => {
      setTimeout(redirect, 1000, data);
    })
    .fail(data => {
      hide_progress();
      enable_button();
    });

    return false;
  });

  function show_progress() {
    $('#progressbar').prop('aria-valuenow', 0);
    $('#progressbar').css('width', '0%');
    $('#progress-container').removeAttr('hidden');
    $('#progress-container').show();
  }

  function hide_progress() {
    $('#progress-container').prop('hidden', true);
  }

  function disable_button() {
    $('#submit-btn').prop('disabled', true);
    $('#submit-btn-sm').prop('disabled', true);
  }

  function enable_button() {
    $('#submit-btn').removeAttr('disabled');
    $('#submit-btn-sm').removeAttr('disabled');
  }

  function redirect(url) {
    location.href = url;
  }

});
```

# ポイント
### ファイルの保存
アップロードファイルのサイズが十分に大きい時、file.multiple_chunks()はTrueを返します。
この場合、read()の代わりにfile.chunks()でイテレーションを回すことで、メモリが専有されることを防げます。
```python:sample_project/file_upload/views.py
# ファイルを保存
file = form.cleaned_data['file']
with open(f'{file.name}', 'wb+') as destination:
    if file.multiple_chunks():
        for chunk in file.chunks():
            destination.write(chunk)
    else:
        destination.write(file.read())
```

### 送信元がAjaxの場合の分岐
`FileUploadSampleView.__is_ajax()`で、POSTリクエストの送信元がAjaxであるか否かを判定しています。送信元がAjaxの場合、レスポンスとしてsuccess_urlをそのまま文字列として返してあげることで、jQueryでページ遷移する際に、遷移先を.jsファイルにハードコーディングしてしまうことを防げます。
```python:sample_project/file_upload/views.py
# AjaxによるPOSTの場合はリダイレクト先のURLをのものを返す
if self.__is_ajax():
    return HttpResponse(self.success_url)
```

### プログレスバーの描画
Ajaxの通信中に、定期的に送信済みのデータサイズから進捗度合いを計算し、Bootstrapのプログレスバーの表示を更新しています。
```javascript:sample_project/file_upload/static/upload.js 
// Ajaxでアップロード
$.ajax({
  type: 'POST',
  processData: false,
  contentType: false,
  headers: {
    'X-CSRFToken': $('input[name="csrfmiddlewaretoken"]').val()
  },
  data: myform,
  xhr : function(){
    // プログレスバーの更新
    var XHR = $.ajaxSettings.xhr();
    if(XHR.upload){
        XHR.upload.addEventListener('progress',function(e){
            var progress = parseInt(e.loaded/e.total*100);
            $('#progressbar').prop('aria-valuenow', progress);
            $('#progressbar').css('width', progress + '%');
        }, false);
    }
    return XHR;
  },
})
```

# 参考資料
https://docs.djangoproject.com/en/5.0/topics/http/file-uploads/
https://bootstrap-cheatsheet.themeselection.com/#progress-bar-animated