djangoで画像のドラッグアンドドロップの実装3 -完成-

djangoのプロフィール画像設定で、ドラッグアンドドロップを用いたプロフィール画像の編集ができるコードを紹介します。

前回続きから、今回が完成ということで画像をドラッグアンドドロップしてサーバー側(djangoのviews)でsaveまでのコードを紹介します。

サーバー側の理解ができている人は、1と2を読まなくても、この実装3だけを読んで理解することが可能です。

こちらが前回記事になります。

完成イメージ

まずは完成後のイメージを動画で確認ください。

プロフィール画像の変更を、ドラッグアンドドロップもしくは、通常のformでの両方の入力欄を設置し想定しています。(メインはあくまでドラッグアンドドロップ)

そしてドラッグアンドドロップして問題なければ、そのままajaxを利用してバックエンドのdjangoでsaveまでしてフロントに反映させるまでを想定します。

こんな人向け

以下の人向けに記事を書いています。

・javascript(jQuery)のAjaxでドラッグアンドドロップを実装したい

・djangoのドラッグアンドドロップでサーバーに画像を保存したい

環境

環境は以下になります。ちなみにヒロヤンの場合はpipenvでの環境開発を行っています。

環境

・OS Mac

・python 3.8

・djano 2.2

・jQuery 3.5.1

・Pillow インストール済み

・Ajaxが理解できている

実際のコード

それでは早速紹介をしていきます。

HTML

sample.html

<body>
<h1>プロフィール画像のアップロード</h1>

<table class="edit-profile-table">
    <tr>
        <td>
            <img src="{{ MEDIA_URL }}{{ avator }}" alt="" class="avator">
            <div id="ajax-response"></div>
        </td>
    </tr>
    <tr>
        <td>
            <div id="dragandrophandler">ここにプロフィール写真をドラッグ</div>
            <div id="preview"></div>
            <form action="" method="post" enctype="multipart/form-data">
            {# csrf_token #}
            <div>{{ form.avator }}{{ form.avator.errors }}</div>
            <button type="submit" class="btn">登録</button>
            </form>
        </td>
    </tr>
</table>

<!-- Modal dialog -->
<div class="popup" id="js-popup">
    <div class="popup-inner">
        <div class="close-btn" id="js-close-btn">
            <div class="close-icon"></div>
        </div>
        <div class="container">
            <a href="#">
                <div id="popup-preview"></div>
            </a>
            <p class="item">こちらの画像を登録しますか?</p>
            <button class="item btn" id="popup-btn-y">はい</button>
            <button class="item btn" id="popup-btn-n">いいえ</button>
        </div>
    </div>
    <div class="back-background" id="js-black-bg"></div>
</div>
<!-- Modal dialog -->



{% endblock %}
{% load static %}
{% block extra_js %}
<script src="{% static 'sample/js/sample.js' %}"></script>
{% endblock %}

CSS

sample.css


/* profile */

.edit-profile-table{
    box-sizing: border-box;
    border: 1px solid rgba(241, 70, 233, 0.377);
    border-radius: 5px;
    text-align: center;
    margin: 0 auto;
}

.edit-profile-table tr th ul{
    text-align:left;
}

.edit-profile-table tr td .avator{
    margin: 15px;
    border-radius: 50%;
    border: 3px solid #f1a2e7;
}


/* drag and drop */
#dragandrophandler{
    border: 2px dotted #0B85A1;
    height: 100px;
    color: #92AAB0;
    text-align: left;
    padding: 10px;
    margin-bottom: 10px;
    font-size: 2em;
}

.edit-profile-table tr td div,
.edit-profile-table tr td button{
    margin: 13px;
}
/* drag and drop */

/* modal dialog */
.popup{
    position: fixed;
    box-sizing: border-box;
    left: 0;
    top: 0;
    width: 100%;
    height: 100%;
    z-index: 9999;
    opacity: 0;
    visibility: hidden;
    transition: .6s;
}

.popup.is-show{
    opacity: 1;
    visibility: visible;
}

.popup-inner{
    position: absolute;
    left: 50%;
    top: 50%;
    transform: translate(-50%, -50%);
    width: 270px;
    max-width: 600px;
    padding: 50px;
    background-color: #fff;
    z-index: 2;
}

.close-btn{
    position: absolute;
    right: 0;
    top: 0;
    width: 50px;
    height: 50px;
    line-height: 50px;
    text-align: center;
    cursor: pointer;
}

.close-btn .close-icon{
    font-size: 21px;
    position: relative;
    width: 1.4em;
    height: 1.4em;
    border: 10px solid #39a9d6;
    border-radius: 100%;
}

.close-btn .close-icon::before{
    position: absolute;
    top: 0.2em;
    left: 0.6em;
    width: 0.2em;
    height: 1em;
    content: "";
    background-color: #39a9d6;
    transform: rotate(45deg);
}

.close-btn .close-icon::after{
    position: absolute;
    top: 0.6em;
    left: 0.2em;
    width: 1em;
    height: 0.2em;
    content: "";
    background-color: #39a9d6;
    transform: rotate(225deg);
}

.back-background{
    position: absolute;
    left: 0;
    top: 0;
    width: 100%;
    height: 100%;
    background-color: rgba(0, 0, 0, .8);
    z-index: 1;
    cursor: pointer;
}

.popup .container{
    border: 1px dotted #f00;
    display: inline-block;
    text-align: center;
    padding: 20px;
}

.resize_prev_img{
    width: 180px;
    height: 180px;
    border-radius: 50%;
}

/* modal dialog */

JS

sample.js

'use strict';

function sendFileToServer(formData, status){
    var uploadURL = '/アプリ名/指定のURL/';
    var jqXHR = $.ajax({
        // 以下が$.ajaxに渡すパラメータ
        xhr: function() {
            // XMLHttpRequest (XHR) はJavaScriptなどのウェブブラウザ搭載のスクリプト言語でサーバとのHTTP通信を行うための、組み込みオブジェクト(API)
            var xhrobj = $.ajaxSettings.xhr();
            if (xhrobj.upload) {
                // addEventListner(第一引数がクリックとかのイベント、第二引数は実際に起こるイベント)
                xhrobj.upload.addEventListener('progress', function(event){
                    var percent = 0;
                    var position = event.loaded || event.position;
                    var total = event.total;
                }, false);
            }
            return xhrobj;
        },
        url: uploadURL,
        type: 'POST',
        contentType: false,
        processData: false,
        cache: false,
        data: formData,
        dataType: 'json',
    })
        .done(function (data) {
            if (data['success'] === true) {
                // alert('success!');
                $('.edit-profile-table tbody tr td img').remove();
                $('#ajax-response')
                    .html(
                        '<img src="/media/' +
                        data['profile_img_path'] +
                        '" class="avator" >'
                    );
            }else {
                // alert('false');
                $('#preview').remove();
            };
        })
        .fail(function() {
            alert('Ajax failed');
        });
}


// drag and drop event
$(document).ready(function() {
    var obj = $("#dragandrophandler");
    obj.on('dragenter', function (e) {
        e.stopPropagation();
        e.preventDefault();
        $(this).css('border', '2px solid #0B85A1');
    });
    obj.on('dragover', function (e) {
        e.stopPropagation();
        e.preventDefault();
    });
    obj.on('drop', function(e) {
        $(this).css('border', '2px dotted #0B85A1');
        e.preventDefault();

        var files = e.originalEvent.dataTransfer.files;
        
        // Modal dialog
        popupImage(files, obj);
    });

    // Avoid opening in a browser if the file is dropped outside the div
    $(document).on('dragenter', function (e) {
        e.stopPropagation();
        e.preventDefault();
    });

    $(document).on('dragover', function (e) {
        e.stopPropagation();
        e.preventDefault();
        obj.css('border', '2px dotted #0B85A1');
    });

    $(document).on('drop', function (e) {
        e.stopPropagation();
        e.preventDefault();
    });    
})

// modal dialog
function popupImage(files, obj) {
    var popup = document.getElementById('js-popup');
    if (!popup) return;

    var fd = new FormData();
    var filetype = files[0].type;

    $('.errorlist').remove();

    // 1. File format
    if (!(filetype.match('^image\/(png|jpeg)$'))){
        $('#dragandrophandler').after('<ul class="errorlist"><li>ファイル形式が、pngもしくはjpeg以外のものは使用できません。</li></ul>');
        return false;
    }

    // 2. File size
    var sizeKB = files[0].size / 1024;
    if (parseInt(sizeKB) > 1024) {
        var sizeMB = sizeKB / 1024;
        if (sizeMB > 20) {
            $('#dragandrophandler').after('<ul class="errorlist"><li>画像サイズが大きすぎます。20MBより小さいサイズの画像をお願いします。</li></ul>');
            return false;
        };
    };

    fd.append('avator', files[0])

    // preview
    var fileReader = new FileReader();
    fileReader.onloadend = function () {
        $('#popup-preview').html('<img src="' + fileReader.result + '"/>');
        // リサイズのクラス
        $('#popup-preview img').addClass('resize_prev_img', 'item');
    }
    fileReader.readAsDataURL(files[0]);

    // 閉じる
    var blackBg = document.getElementById('js-black-bg');
    var closeBtn = document.getElementById('js-close-btn');
    var showBtn = document.getElementById('js-show-popup');
    var popupBtnN = document.getElementById('popup-btn-n');

    popup.classList.add('is-show');

     // btn yesのときの処理
    $('#popup-btn-y').off('click');
    $('#popup-btn-y').on('click', function () {
         // $('ul.errorlist').remove();
         // サーバーにデータ送信
        sendFileToServer(fd, status);
        popup.classList.remove('is-show');
        $('#dragandrophandler+div.statusbar').remove();
    });

    // 関数呼び出し
    closePopUp(blackBg);
    closePopUp(closeBtn);
    closePopUp(showBtn);
    closePopUp(popupBtnN);
    // closeボタン
    function closePopUp(elem) {
        if (!elem) return;
        elem.addEventListener('click', function () {
            popup.classList.remove('is-show');
            $('#dragandrophandler+div.statusbar').remove();
        });
    };
};

補足説明を以下にします。

  1. 3行目から47行目までがサーバー(views)に画像データを送るajax
  2. 21行目のtypeはHTTP通信の種類指定
  3. 23行目のprocessDataはパラメーターの内容をクエリ情報に変換(デフォルトはtrue)。オブジェクトでデータを取得する場合に記載すると覚えればOK
  4. 24行目のcacheは通信データをキャッシュするか(デフォルトはtrue)
  5. 25行目のdataは送信するデータ
  6. 26行目のdataTypeは、サーバー(views)から返るデータの指定。今回はjsonで指定
  7. 28-37行目でajaxが成功した時の動作。既に存在するimgタグを削除し新たなimgタグを32-36行目で設置
  8. 38-40行目でajaxに成功したけどサーバー側のバリデーションに引っかかった時の処理
  9. 43-45行目はajaxに失敗した時の処理
  10. 50-87行目までが、ドラッグアンドドロップした時のUIの移り変わりの動作
  11. 100-113行目でドロップされた画像のバリデーション
  12. 100-103行目では、正規表現でpngファイル、もしくはjpegファイル以外がドロップされた場合は、警告のerrorlistを表示し、ドロップできないように制御
  13. 106-112行目ではドロップされた画像が20MB以上の場合は、警告のerrorlistを表示し、ドロップできないように制御
  14. 115行目は割と重要なコード。appendの第一引数の’avator’とすり合わせる必要がある
  15. 118-124行目がプレビュー表示
  16. 127-130行目でモーダルダイアログが表示された時の閉じる一覧の変数定義
  17. 135-142行目でモーダルダイアログが表示され、136行目で「はい」でクリックされ、139行目でサーバーに画像データを送る関数を呼び出す
  18. 145-156行目で閉じる関数を呼び出しています。

これでフロント側の準備ができました。続いてサーバー側(views)での処理コードを書いていきます。

views

以下にviews.pyを書いていきます。今回はmodelsとformsは割愛します。

過去記事にmodelsとformsのことも書いておりますので、そちらからでも参考にできます。

views.py

import json

from PIL import Image
# from django.core.files import File
# from datetime import datetime
from django.views.decorators.csrf import csrf_exempt
from django.http import JsonResponse
from .models import UserProfile
from .forms UpLoadProfileImgForm

# not apper csrf_exempt
@csrf_exempt
def edit_profile_avator(request):
    user_profile = UserProfile.objects.get(user_id=request.user.id)

    if request.method != 'POST':
        form = UpLoadProfileImgForm()

    else:
        if request.is_ajax():
            if request.method == 'GET':
                return JsonResponse({})

            if request.method == 'POST':
                form = UpLoadProfileImgForm(request.POST, request.FILES)
                if not form.is_valid():
                    json_dict = json.loads(form.errors.as_json())
                    json_msg_list = []
                    for json_msg_value in json_dict.values():
                        json_msg_list.append(json_msg_value[0]['message'])

                    data = {
                        'success': False,
                        'error_messages': json_msg_list,
                    }

                    return JsonResponse(data=data)

                else:
                    if user_profile.avator:
                        os.remove('media/' + str(user_profile.avator))
                        user_profile.delete()


                    avator = form.cleaned_data['avator']
                    user_profile = UserProfile()
                    user_profile.user_id = request.user.id
                    user_profile.avator = avator
                    user_profile.save()

                    user = UserProfile.objects.get(id=request.user.id)
                    data = {
                        'success': True,
                        'profile_img_path': user.avator,
                    }

                    return JsonResponse(data=data)

        form = UpLoadProfileImgForm(request.POST, request.FILES)
        if form.is_valid():
            user_profile = UserProfile.objects.get(user_id=request.user.id)
            if user_profile.avator:
                os.remove('media/' + str(user_profile.avator))
                user_profile.delete()

            avator = form.cleaned_data['avator']
            user_profile = UserProfile()
            user_profile.user_id = request.user.id
            user_profile.avator = avator
            user_profile.save()

            return HttpResponseRedirect(reverse('inquiry_apps:edit_profile_success'))


    context = {
        'avator': user_profile.avator,
        'form': form
    }
    return render(request, 'inquiry_apps/edit_profile/edit_avator.html', context)

補足説明を以下にします。

  1. 11行目のcsrf_exemptでcsrfを制御。モジュールを6行目で使えるようにする
  2. 20行目でajaxが有効な時のコードを以下に書く
  3. 24行目がフロントでモーダルダイアログが表示されて「はい」を選択(post)した時の動作
  4. 26-37行目で画像ファイルがformでバリデーションに引っかかった時はfalseを返す
  5. 39行目以下がajaxが成功且つバリデーションも問題なしの時の処理
  6. 40-42行目でまず既に存在する同じidを持つ画像データを削除
  7. 45-49行目で画像を保存
  8. 52-55行目がajaxに返す辞書型オブジェクト
  9. 57行目でjsonデータをしてajaxに返す
  10. 59-72行目はajaxではなくて通常のpost->formからの画像投稿の処理

以上です。上記のviews側でajaxにjsonデータで返すまでを理解できれば色々と応用できることが増えるかと思います。




最後に

今回のdjangoでドラッグアンドドロップというのは中々ネットで探しても情報がないので書きました。

全体像を理解するのは非常に大変ですが、Ajaxの与える値や戻り値周りを理解することができれば、しっかりとコードを書くことができます。

今回のコードは雑多な部分もあるかと思いますので、もし不明点等がありましたら、こちらのコメント欄や問い合わせからでも構いませんのでご指摘いただければと思いますのでよろしくお願いします。

スクールを利用して本格的に学ぶ

いかがでしたでしょうか?

10人中9人が挫折すると言われるプログラミングを、ヒロヤンも実はプログラミングスクールで学習をしてきたからで、結果、今はPythonエンジニアとして働いています。

挫折率が高いプログラミングこそお金を払ってメンターを付けて、道を見失わないように環境を構築する必要があるのではないでしょうか。

これはダイエットで自分一人では痩せられないけど、トレーナーを付けて否が応でもせざるを得ない環境を作ると一緒ですね。

ヒロヤンもプログラミング勉強開始直後はあれこれ悩みましたが、悩むよりも手っ取り早くスクールに登録した方が最短ルートで勉強できるのではないかと考え、結果挫折せずに今に至っています。

今なら無料でキャリアカウンセリングを行っているCodeCamp(コードキャンプ)のようなプログラミングスクールもありますのでこれを機会に是非カウンセリングだけでも受けてみてはいかがでしょうか?

CodeCampで無料のカウンセリング

上記リンク先から無料相談ができます。

またこちら(↓)ではPython専用のプログラミングスクールをまとめ紹介しています。

コメントを残す

メールアドレスが公開されることはありません。 ※ が付いている欄は必須項目です