LoginSignup
28
35

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