28
35

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

DjangoでCSVアップロード~db登録~バリデーションの機能を作る

Last updated at Posted at 2018-07-06

#はじめに
ん~、最近Webの勉強が楽しい!
さくらのVPS借りてhelloDjangoするまでにサーバーまわりで挫折を繰り返して1年半近く費やしたからな。。。画面が映ることが保証されてる状態の楽しさったらないわ。ルンルンですわ:relaxed:
Qiitaもはかどる!どんどんいこう!

#今回作る機能
CSVをブラウザからアップロードして、MySQLに登録。即座にグラフに反映される

#読める!読めるぞ!!
むむっ!?気がついたら公式ドキュメントが読めるようになってきてるじゃないか!?完全に導入時点の壁超えた感じある:relaxed:
ここに来るまでに何度挫折を味わったか...
image.png

Django公式:ファイルのアップロード
bootstrap4公式:フォーム
Djangoでファイルアップローダを作る

#フォームの作成
##おさらい(フォームとは?)
ほれ、ユーザーからデータがきたぞ~。っていうこれのことです!(アンケート入力して送信~って仕組み)
image.png

CentOS(/home/op/app01/public_html)
$ vi pj1/app1/forms.py
forms.py(新規作成)
from django import forms

class UploadFileForm(forms.Form):
    # formのname 属性が 'file' になる
    file = forms.FileField(required=True, label='')
CentOS(/home/op/app01/public_html)
$ vi pj1/app1/views.py
views.py
from django.shortcuts import render, redirect
from .models import evaluation
from .forms import UploadFileForm
import os
UPLOAD_DIR = os.path.dirname(os.path.abspath(__file__)) + '/static/files/'

def index(request):
    if request.method == 'POST':
        # POST
        form = UploadFileForm(request.POST, request.FILES)
        if form.is_valid():
            # file is saved. by binary
            f = request.FILES['file']
            path = os.path.join(UPLOAD_DIR, f.name)
            with open(path, 'wb') as destination:
                for chunk in f.chunks():
                    destination.write(chunk)
            return redirect('index')
    else:
        # GET
        form = UploadFileForm()
        qry = evaluation.objects.order_by('-ym')[:1].values('ym','homogeneity','plowing','biological','chemical','hardness')
        return render(request, 'index.html', {'rs': qry, 'cs':qry[0].keys(), 'form':form})
CentOS(/home/op/app01/public_html)
$ vi pj1/app1/templates/index.html
index.html(dbデータを並べた下にフォームパーツを書いた)
<form method="POST" enctype="multipart/form-data">
    {% csrf_token %}
    {{ form }}
    <div class="form-group">
        <button type="submit">UPLOAD</button>
    </div>
</form>

##アップロード先のディレクトリをつくる

CentOS
$ mkdir pj1/app1/static/files

##日本語対応
ファイル名に日本語(全角文字)が含まれているファイルをアップロードするための設定

CentOS(/home/op/app01/public_html)
$ sudo vi /etc/sysconfig/httpd
httpd
LANG='ja_JP.UTF-8'
CentOS(/home/op/app01/public_html)
$ systemctl restart httpd 

##UPLOADテスト

1.画面ができている
image.png

2.ファイルが選択されていない状態でアップロードボタン押すとはじかれる
image.png

3.ファイル見てみようぜ

CentOS(/home/op/app01/public_html)
$ vi pj1/app1/static/files/sample.csv

yes!:relaxed:
image.png

##UPLOAD先からcsvを読んでDB登録
PythonスクリプトでDjangoにデータを登録する

CentOS(/home/op/app01/public_html)
$ vi pj1/app1/views.py
views.py
from django.shortcuts import render, redirect
from .models import evaluation
from .forms import UploadFileForm
import os, csv
UPLOAD_DIR = os.path.dirname(os.path.abspath(__file__)) + '/static/files/'

def index(request):
    if request.method == 'POST':
        # POST
        form = UploadFileForm(request.POST, request.FILES)
        if form.is_valid():
            # file is saved. by binary
            f = request.FILES['file']
            path = os.path.join(UPLOAD_DIR, f.name)
            with open(path, 'wb') as destination:
                for chunk in f.chunks():
                    destination.write(chunk)

            # open csv
            with open(path, 'r') as destination:
                # read csv
                rdr = csv.reader(destination)
                # ignore header
                next(rdr)
                # upsert
                for r in rdr:
                    ev = evaluation()
                    ev.ym = r[0]
                    ev.homogeneity = float(r[1])
                    ev.plowing = float(r[2])
                    ev.biological = float(r[3])
                    ev.chemical = float(r[4])
                    ev.hardness = float(r[5])
                    ev.save()
            return redirect('index')
    else:
        # GET
        form = UploadFileForm()
        qry = evaluation.objects.order_by('-ym')[:1].values('ym','homogeneity','plowing','biological','chemical','hardness')
        return render(request, 'index.html', {'rs': qry, 'cs':qry[0].keys(), 'form':form})
CentOS(/home/op/app01/public_html)
$ systemctl restart httpd 

##登録できた!
おおぉ~!やったやんけ~!

image.png

##グラフも即座に変わった!
「systemctl restart httpd」やらなくても平気になったな!

image.png

#バリデーション強化
ここでまたバリデーションについて公式ドキュメントと格闘することに。なんで英語なんだよー:head_bandage:
公式:フォームとフィールドの検証
Django、CSVのインポート・エクスポート ~バリデーション~

##入力チェックの動き

■Django フォームのバリデーション (入力検証)
Django のフォームシステムでは、デフォルトの入力検証が終わった後に、clean_ から始まり、 フィールド名のついたメソッドを呼出します。この clean_* メソッドによって、カスタムの入力検証を行うことができます。 例えば、email というフィールドに対しては、clean_email というメソッド名で入力検証を行います。

##入力チェックが行われるタイミング
ふんふん、なるほど?
image.png

###待てよ?
タッカーさん。もひとつ質問いいかな。is_valid()がしくじったときって何の画面が返ってるんだ?
image.png

実際の動き的には、アップロードボタンを押したときに(画面遷移なしで)ここがダメだよって怒られるんだから、入力チェックが終わった後の動きが尻切れトンボで、結果的に動き(画面遷移)なし、ってことか?まぁ納得はできる動きか?

いや?やっぱり変だぞ?
とりあえず、ダメなパターンでアップロードしようとして、、、っと
image.png

HTMLが返らない??
image.png

DebugMsgをよいしょっと
image.png

ほらきた!
予想通りのエラーが起きたのはある意味では気持ちいいけど...w
公式ドキュメントはこの辺は考慮されていないんだな。
image.png

###returnの位置を移動する
わかります?一番下の2行のインデントが左に寄りました。これによって、POSTにいったあとの最後で index に redirect してたやつが省力化できた。
image.png

views.py
from django.shortcuts import render, redirect
from .models import evaluation
from .forms import UploadFileForm
import os, csv
UPLOAD_DIR = os.path.dirname(os.path.abspath(__file__)) + '/static/files/'

def index(request):
    if request.method == 'POST':
        # POST
        form = UploadFileForm(request.POST, request.FILES)
        if form.is_valid():
            # file is saved. by binary
            f = request.FILES['file']
            path = os.path.join(UPLOAD_DIR, f.name)
            with open(path, 'wb') as destination:
                for chunk in f.chunks():
                    destination.write(chunk)
            # open csv
            with open(path, 'r') as destination:
                # read csv
                rdr = csv.reader(destination)
                # ignore header
                next(rdr)
                # upsert
                for r in rdr:
                    ev = evaluation()
                    ev.ym = r[0]
                    ev.homogeneity = float(r[1])
                    ev.plowing = float(r[2])
                    ev.biological = float(r[3])
                    ev.chemical = float(r[4])
                    ev.hardness = float(r[5])
                    ev.save()
    else:
        # GET
        form = UploadFileForm()

    qry = evaluation.objects.order_by('-ym')[:1].values('ym','homogeneity','plowing','biological','chemical','hardness')
    return render(request, 'index.html', {'rs': qry, 'cs':qry[0].keys(), 'form':form})

##CSVのみ許容する
xxxというフォームフィールドを作ったら「is_valid()」が走ったときに clean_xxx がトリガされるのね。で、入力チェックが終わると self.cleaned_data というプロパティに、入力チェックが完了した入力データがコピーされるっぽい

CentOS(/home/op/app01/public_html)
$ vi pj1/app1/forms.py
forms.py
from django import forms

class UploadFileForm(forms.Form):
    # formのname 属性が 'file' になる
    file = forms.FileField(required=True, label='')

    # add
    def clean_file(self):
        file = self.cleaned_data['file']
        if file.name.endswith('.csv'):
            return file
        else:
            raise forms.ValidationError('拡張子はcsvのみです')
CentOS(/home/op/app01/public_html)
$ systemctl restart httpd 

###CSV以外をハジいた!
「form.is_valid()でエラーが発生すると form の中にエラー情報を抱え込むので、そのまま form を render に投げて template に渡ればエラーがでたあとの index.html に遷移する!」これだ。メッセージの出かたがダサいのはCSSかなんかで吹き出しにできるのかな?
image.png

###エラーメッセージにCSS
form がエラーを抱えて render されると、errorlist としてクラスがセットされて書き出される。

index.html(formがエラーを握ってrenderされた場合)
<ul class="errorlist">
    <li>拡張子がcsvのファイルをアップロードしてください</li>
</ul>

で、スタイルが勝手に適用されると思ってたんだけど、、、
Adding css class to field on validation error in django

やっぱそんなことないかww
image.png

・html li リストマーク(・)消す方法
・おしゃれなボックスデザイン(囲み枠)のサンプル30

CentOS(/home/op/app01/public_html)
$ mkdir pj1/app1/static/css
$ vi pj1/app1/static/css/styles.css
styles.css
.errorlist {
    padding: 0.5em 1em;
    margin: 2em 0;
    color: #ff7d6e;
    background: #ffebe9;
    border-top: solid 10px #ff7d6e;
}

.errorlist li {
    margin: 0;
    padding: 0;
    list-style: none;
}
index.html(pj1/app1/templates)
{% load static %}
<link rel="stylesheet" type="text/css" href="{% static 'css/styles.css' %}" />
CentOS(/home/op/app01/public_html)
$ systemctl restart httpd 

###できた!
image.png

##項目数が正しいか
バリデーション

#項目数バリデーションから発展して結構変える羽目になった
苦しめられてるのは、リストとか辞書でテンプレートに投げたデータの、テンプレート側での開き方だよなぁ~

あと、当たり前っちゃ当たり前だけど「CSVのバリデーションは、フォームのバリデーションじゃない」ってことに気がついた。

views.py(つまり、form.ValidationErrorはダメ)
if len(r) == len(HEADER):
    
    
    
else:
    raise form.ValidationError('{0}行目、項目数エラー'.format(i))

・nレコードを表示する前提だったけど .first で1レコードだけ返すことにした
スライス「:1」で1レコードだけに切ったときと「.first()」で完全に1レコードしか渡さなかったときとはテンプレート側での動きが違うようだ。ヤヤコシス。
・上記の結果、テンプレート側でループ構文にアテなくても良くなった(シンプルになった)
・0レコードだったとき、に対応した

index.html ~ヘッダー部~
・CSSを使うよ!という宣言と、CSSへのパスを追加
image.png

index.html ~データテーブル部~
・「.first()」を使うことでループ構文にアテなくても良くなった
image.png

index.html ~javascript グラフデータ代入部~
・「.first()」を使うことでループ構文にアテなくても良くなった
image.png

view.py ~冒頭~
・「evaluation(=評価)」というテーブルには隠れ項目「id」列があるため、それ以外の項目を列挙した。POSTルート時の「CSVとテーブルの項目数の整合性点検」のときにも使われている
image.png

view.py ~db登録部とクエリ部とリターン部~
・CSV項目数とテーブルの規定項目数が違うときという診断を入れた
add_error で form のエラーに成りすませることを発見!
・0レコードだったとき、に対応
・nレコードを表示する前提だったけど .first で1レコードだけ返すことにした
image.png

##できた!
image.png

add_errorを見つけるまでにだいぶ時間を食った...
   /.   ノ、i.|i     、、         ヽ 
  i    | ミ.\ヾヽ、___ヾヽヾ        | 
  |   i 、ヽ_ヽ、_i  , / `__,;―'彡-i     | 
  i  ,'i/ `,ニ=ミ`-、ヾ三''―-―' /    .| 
   iイ | |' ;'((   ,;/ '~ ゛   ̄`;)" c ミ     i. 
   .i i.| ' ,||  i| ._ _-i    ||:i   | r-、  ヽ、   /    /   /  | _|_ ― // ̄7l l _|_ 
   丿 `| ((  _゛_i__`'    (( ;   ノ// i |ヽi. _/|  _/|    /   |  |  ― / \/    |  ――― 
  /    i ||  i` - -、` i    ノノ  'i /ヽ | ヽ     |    |  /    |   丿 _/  /     丿 
  'ノ  .. i ))  '--、_`7   ((   , 'i ノノ  ヽ 
 ノ     Y  `--  "    ))  ノ ""i    ヽ 
      ノヽ、       ノノ  _/   i     \ 
     /ヽ ヽヽ、___,;//--'";;"  ,/ヽ、    ヾヽ

#ソースの整理

views.py
from django.shortcuts import render, redirect
from .models import evaluation
from .forms import UploadFileForm
import os, csv
UPLOAD_DIR = os.path.dirname(os.path.abspath(__file__)) + '/static/files/'

def index(request):
    HEADER = ['ym','homogeneity','plowing','biological','chemical','hardness']
    if request.method == 'POST':
        # POST
        form = UploadFileForm(request.POST, request.FILES)
        if form.is_valid():
            # file is saved. by binary
            f = request.FILES['file']
            path = os.path.join(UPLOAD_DIR, f.name)
            with open(path, 'wb') as destination:
                for chunk in f.chunks():
                    destination.write(chunk)

            # open csv
            with open(path, 'r') as destination:
                # read csv
                rdr = csv.reader(destination)
                # csv records count (first record is header)
                cnt = sum(1 for r in destination)
                destination.seek(0)
                # When there is a records.
                if cnt >0:
                    # ignore header
                    next(rdr)
                    # upsert
                    for i, r in enumerate(rdr, 1):
                        if len(r) == len(HEADER):
                            ev = evaluation()
                            ev.ym = r[0]
                            ev.homogeneity = float(r[1])
                            ev.plowing = float(r[2])
                            ev.biological = float(r[3])
                            ev.chemical = float(r[4])
                            ev.hardness = float(r[5])
                            ev.save()
                        else:
                            form.add_error(None,'{0}行目 項目数エラー(規定項目数:{1}, CSV項目数:{2})'.format(i, len(HEADER), len(r)))
    else:
        # GET
        form = UploadFileForm()

    # Assemble and Exec SQL
    qry = evaluation.objects.all()
    if qry.count() >0:
        qry = evaluation.objects.order_by('-ym').first()

    # Rendering
    return render(request, 'index.html', {'rs': qry, 'cs':HEADER, 'form':form})
index.html
<!DOCTYPE html>
{% load staticfiles %}
{% load static %}
<html lang="ja">

  <head>
    <!-- Stylesheet and meta necessary for Bootstrap -->
    <meta http-equiv="content-type" content="text/html; charset=UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.0/css/bootstrap.min.css" integrity="sha384-9gVQ4dYFwwWSjIDZnLEWnxCjeSWFphJiwGPXr1jddIhOegiu1FwO5qRGvFXOdJZ4" crossorigin="anonymous">
    <link rel="stylesheet" type="text/css" href="{% static 'css/styles.css' %}" />

    <!-- Google fonts -->
    <link href='http://fonts.googleapis.com/css?family=Open+Sans:400,300' rel='stylesheet' type='text/css'>
    <link href='https://fonts.googleapis.com/css?family=Raleway' rel='stylesheet' type='text/css'>

    <!-- D3.js -->
    <script src="https://d3js.org/d3.v4.min.js" charset="utf-8"></script>
    <script src="https://d3js.org/d3-path.v1.min.js" charset="utf-8"></script>
    <script src="{% static 'js/radarChart.js' %}" charset="utf-8"></script>
    <style>
        .legend {
            font-family: 'Raleway', sans-serif;
            fill: #333333;
        }
    </style>
    <title>Anaconda!</title>

  </head>

  <body>

    <h1>Hello, Bootstrap world!</h1>
    <table class="table table-dark table-hover">
      <thead>
        <tr>
        {% for c in cs %}
          <td>{{ c }}</td>
        {% endfor %}
        </tr>
      </thead>
      <tbody>
        <tr>
        <td>{{ rs.ym }}</td>
        <td>{{ rs.homogeneity }}</td>
        <td>{{ rs.plowing }}</td>
        <td>{{ rs.biological }}</td>
        <td>{{ rs.chemical }}</td>
        <td>{{ rs.hardness }}</td>
        </tr>
      </tbody>
    </table>

    <form method="POST" enctype="multipart/form-data">
        {% csrf_token %}
        {{ form }}
        <div class="form-group">
            <button type="submit">UPLOAD</button>
        </div>
    </form>

    <div class="radarChart" style="display: inline-flex;"></div>
    <script>
        //////////////////////////////////////////////////////////////
        //////////////////////// Set-Up //////////////////////////////
        //////////////////////////////////////////////////////////////
        var margin = { top: 50, right: 80, bottom: 50, left: 80 },
        width = Math.min(700, window.innerWidth / 4) - margin.left - margin.right,
        height = Math.min(width, window.innerHeight - margin.top - margin.bottom);

        //////////////////////////////////////////////////////////////
        ////////////////////////// Data //////////////////////////////
        //////////////////////////////////////////////////////////////
        var data = [
        { name: 'Mean',
            axes: [
            {axis: 'homogeneity', value: 40},
            {axis: 'plowing', value: 40},
            {axis: 'biological', value: 40},
            {axis: 'chemical', value: 40},
            {axis: 'hardness', value: 40}
            ]
        },
        { name: 'You',
            axes: [
            {axis: 'homogeneity', value: 0},
            {axis: 'plowing', value: 0},
            {axis: 'biological', value: 0},
            {axis: 'chemical', value: 0},
            {axis: 'hardness', value: 0}
            ]
        }
        ];

        // views.py からの代入
        data[1].axes[0].value = parseFloat("{{ rs.homogeneity }}");
        data[1].axes[1].value = parseFloat("{{ rs.plowing }}");
        data[1].axes[2].value = parseFloat("{{ rs.biological }}");
        data[1].axes[3].value = parseFloat("{{ rs.chemical }}");
        data[1].axes[4].value = parseFloat("{{ rs.hardness }}");

        var radarChartOptions = {
          w: 290,
          h: 350,
          margin: margin,
          maxValue: 60,
          levels: 6,
          roundStrokes: false,
          color: d3.scaleOrdinal().range(["#AFC52F", "#ff6600"]),
        format: '.0f',
        legend: { title: 'TOTAL EVALUATION', translateX: 100, translateY: 40 }
        };
        RadarChart(".radarChart", data, radarChartOptions);

    </script>
    <!-- jQuery necessary for Bootstrap -->
    <script src="https://code.jquery.com/jquery-3.3.1.slim.min.js" integrity="sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo" crossorigin="anonymous"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.0/umd/popper.min.js" integrity="sha384-cs/chFZiN24E4KMATLdqdvsezGxaGsi4hLGOzlXwp5UZB1LY//20VyM2taTB4QvJ" crossorigin="anonymous"></script>
    <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.1.0/js/bootstrap.min.js" integrity="sha384-uefMccjFJAIv6A+rW+L4AHf99KvxDjWSu1z9VI8SKNVmz4sk7buKt/6v9KI65qnm" crossorigin="anonymous"></script>
  </body>
</html>
28
35
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
28
35

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?