naritoブログ

このブログはPython/DjangoとBootstrap4で作成されました
Githubのソースコード

Django、CSVのインポート・エクスポート

約76日前 2018年4月10日1:59
プログラミング関連
Django Python

概要


Djangoで、DB ⇔ CSV なやりとりをするサンプルです。
Githubにもソースコードを置きました

デモ


トップページは、このような感じです。まず、データの一覧が表示されています。
デーたの一覧が表示される


上メニューの「Export」を押すと、posts.csvがダウンロードされます。中身は、一覧で表示されている内容そのままですね。
csvがダウンロードされる


中身を書き換えてみます。pk102のデータのタイトルを「おはようございます」にし、pk105のデータを追加しました。
csvの中身を書き換える


上メニューの「Import」を押し、先程書き換えたcsvを選択します。そして、送信してみると...
書き換えたcsvを送信する


ちゃんと書き換わりましたね。
ページに表示された内容が書き換わっている

ソースコード


ソースコードを見ていきます。アプリケーション名は「app」、Python3.6、Django2.0で確認しています。

urls.py


一覧表示と、インポートと、エクスポートを定義します。
from django.urls import path
from . import views

app_name = 'app'

urlpatterns = [
    path('', views.PostIndex.as_view(), name='index'),
    path('import/', views.PostImport.as_view(), name='import'),
    path('export/', views.post_export, name='export'),
]



models.py


タイトルだけを持つ、シンプルな記事を表すモデルです。
from django.db import models


class Post(models.Model):

    title = models.CharField('タイトル', max_length=50)

    def __str__(self):
        return self.title




forms.py


FileFieldを持つだけの、シンプルなフォームを作っておきます...今のところは!
from django import forms


class CSVUploadForm(forms.Form):
    file = forms.FileField(label='CSVファイル', help_text='※拡張子csvのファイルをアップロードしてください。')



base.html


views.pyの前に、先にテンプレートを紹介します。Bootstrap4を使っています。
<!doctype html>
<html lang="ja">
  <head>
    <!-- Required meta tags -->
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">

    <!-- Bootstrap CSS -->
    <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css" integrity="sha384-Gn5384xqQ1aoWXA+058RXPxPg6fy4IWvTNh0E263XmFcJlSAwiGgFAW/dAiS6JXm" crossorigin="anonymous">

    <title>CSV Upload and Import</title>
  </head>
  <body>
    <ul class="nav justify-content-center">
      <li class="nav-item">
        <a class="nav-link" href="{% url 'app:index' %}">Index</a>
      </li>
      <li class="nav-item">
        <a class="nav-link" href="{% url 'app:import' %}">Import</a>
      </li>
      <li class="nav-item">
        <a class="nav-link" href="{% url 'app:export' %}">Export</a>
      </li>
    </ul>
    <div class="container">
      {% block content %}{% endblock %}
    </div>

    <!-- Optional JavaScript -->
    <!-- jQuery first, then Popper.js, then Bootstrap JS -->
    <script src="https://code.jquery.com/jquery-3.2.1.slim.min.js" integrity="sha384-KJ3o2DKtIkvYIK3UENzmM7KCkRr/rE9/Qpg6aAZGJwFDMVNA/GpGFF93hXpG5KkN" crossorigin="anonymous"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.12.9/umd/popper.min.js" integrity="sha384-ApNbgh9B+Y1QKtv3Rn7W3mgPxhU9K/ScQsAP7hUibX39j7fakFPskvXusvfa0b4Q" crossorigin="anonymous"></script>
    <script src="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/js/bootstrap.min.js" integrity="sha384-JZR6Spejh4U02d8jOt6vLEHfe/JQGiRRSQQxSfFWpi1MquVdAyjUar5+76PVCmYl" crossorigin="anonymous"></script>
  </body>
</html>


post_list.html


トップページ、データの一覧表示画面です。特に言うことはないですね。
{% extends 'app/base.html' %}

{% block content %}
<table class="table">
  <thead>
    <tr>
      <th>pk</th>
      <th>タイトル</th>
    </tr>
  </thead>
  <tbody>
    {% for post in post_list %}
      <tr>
        <td>{{ post.pk }}</td>
        <td>{{ post.title }}</td>
      </tr>
    {% endfor %}
  </tbody>
</table>
{% endblock %}



import.html


CSVをimportするページ
{% extends 'app/base.html' %}

{% block content %}
<form action="" method="POST" enctype="multipart/form-data">
  {{ form.as_ul }}
  {% csrf_token %}
  <button type="submit">送信</button>
</form>
{% endblock %}



views.py


最後に、views.pyです。極力シンプルにしていますが、やや複雑ですね。
import csv
import io
from django.http import HttpResponse
from django.urls import reverse_lazy
from django.views import generic
from .forms import CSVUploadForm
from .models import Post


class PostIndex(generic.ListView):
    model = Post


class PostImport(generic.FormView):
    template_name = 'app/import.html'
    success_url = reverse_lazy('app:index')
    form_class = CSVUploadForm

    def form_valid(self, form):
        # csv.readerに渡すため、TextIOWrapperでテキストモードなファイルに変換
        csvfile = io.TextIOWrapper(form.cleaned_data['file'])
        reader = csv.reader(csvfile)
        # 1行ずつ取り出し、作成していく
        for row in reader:
            post, created = Post.objects.get_or_create(pk=row[0])
            post.title = row[1]
            post.save()
        return super().form_valid(form)


def post_export(request):
    response = HttpResponse(content_type='text/csv')
    response['Content-Disposition'] = 'attachment; filename="posts.csv"'
    # HttpResponseオブジェクトはファイルっぽいオブジェクトなので、csv.writerにそのまま渡せます。
    writer = csv.writer(response)
    for post in Post.objects.all():
        writer.writerow([post.pk, post.title])
    return response



各ビューの解説


views.pyの各ビューを見ていきます。

PostIndex


一覧表示をするためのビューです。これは単純に、一覧表示だけですね。

class PostIndex(generic.ListView):
    model = Post



post_export


データをCSVにエクスポートするビューです。ビュー関数として定義しました。

まずHTTPReponseオブジェクトを作成しますが、場合によっては、charset=Shift-JISだとかutf-8-sig だとかが必要になるかもしれません。
response = HttpResponse(content_type='text/csv')



ファイルをダウンロードさせたい場合は、以下のように書きます。response['Content-Disposition'] = 'attachment; ですね。content_type='text/csv'の時点でブラウザはダウンロードするように判断してくれるはずですが、このように書いておけば確実です。
response['Content-Disposition'] = 'attachment; filename="posts.csv"'


補足しますと、content_type='text/csv' というのはブラウザに対して「これはcsvだ」という情報を伝えます。ブラウザはそれを見て、「csvならブラウザ上で開けないな...ダウンロードさせよう」といった処理をします。「test/plain」や「test/html」等は、「これは開ける」とブラウザが判断し、ダウンロードすることなくブラウザ上で開かれますね。
ただ、以前は(今もそうなのかもしれませんが)この部分をシカトし、ファイルの中身的に開けそうなら開く、といった動作をする子もいました。そういったときのために、response['Content-Disposition'] = 'attachment; とすることでほぼ確実に、ファイルダウンロードを強制させれます。

ファイルアップローダー・ダウンローダーを作ろうとすると、セキィリティ的にこういった強制ダウンロードをさせる(XSS対策)機会があるので、覚えておくと良いでしょう。


後の処理は簡単です。csv.writerにはファイルっぽいオブジェクトを渡せるので、HttpResponseをそのまま渡せます。
    # HttpResponseオブジェクトはファイルっぽいオブジェクトなので、csv.writerにそのまま渡せます。
    writer = csv.writer(response)
    for post in Post.objects.all():
        writer.writerow([post.pk, post.title])
    return response


他の例ですと、ZIPファイルをダウンロードさせる、といったこともできますね。
response = HttpResponse(content_type="application/zip")
response['Content-Disposition'] = 'attachment; filename=sample.zip'
writer = zipfile.ZipFile(response, 'w')
writer.writestr(obj1.image_field.name, obj1.image_field.read())
writer.writestr(obj2.image_field.name, obj2.image_field.read())
writer.close()
return response



PostImport


CSVをインポートして、DBに保存していくビューです。

form.cleaned_data['file'] として取得したデータですが、これはバイナリモードなファイルっぽいオブジェクトです。これをそのままcsv.readerに渡すと「テキストモードで開いたファイルを渡して」と怒られます。そのため、io.TextIOWrapperでラップしています。TextIOWrapperはencoding引数を受け付けるので、場合に寄ってはencoding='utf-8-sig'等をつけると良いかもしれません。
        # csv.readerに渡すため、TextIOWrapperでテキストモードなファイルに変換
        csvfile = io.TextIOWrapper(form.cleaned_data['file'])
        reader = csv.reader(csvfile)



for文で、1行ずつ取り出せます。今回はデータを上書きできるようにしたかったので、まずpkでget_or_createをしています。保存したらrerurn で親のform_validを呼びますが、これはsuccess_urlへのリダイレクトを行うだけです。
        # 1行ずつ取り出し、作成していく
        for row in reader:
            post, created = Post.objects.get_or_create(pk=row[0])
            post.title = row[1]
            post.save()
        return super().form_valid(form)



単純に追加だけで良いならば、Post.objects.create のようにすると良いでしょう。その際はユニークなフィールドを含めないようにします。
一度全データを消してから、CSVのデータを保存するならばPopularPost.objects.all().delete()を最初に呼び出せば良いです。

バリデーション


CSVのインポートに関して、いくつかの良くないケースを考えます。
1. そもそもファイル名が.csv とついていない、csvじゃなさそうなファイル
2. ファイル名に.csvとついているが、中身はCSVではない
3. CSVに足りない列がある、又は列が多い

1はフォーム側で行い、2と3はビューで行うことにします。

forms.py


まず、1のファイル名チェックを実装します。これは非常に簡単に実装できます。
class CSVUploadForm(forms.Form):
    file = forms.FileField(label='CSVファイル', help_text='※拡張子csvのファイルをアップロードしてください。')

    def clean_file(self):
        file = self.cleaned_data['file']
        if file.name.endswith('.csv'):
            return file
        else:
            raise forms.ValidationError('拡張子がcsvのファイルをアップロードしてください')


フォームの表示を凝っていないのでかっこ悪いですが、.csv以外の画像などを渡すとちゃんと動作します、


views.py


長くなりました。
with transaction.atomic()でのロールバックや、ビューからform.add_errorでフォームフィールドへのエラー追加、処理をわかりやすくするためのカスタム例外の利用、等をしています。
class InvalidColumnsExcepion(Exception):
    """CSVの列が足りなかったり多かったりしたらこのエラー"""
    pass


class InvalidSourceExcepion(Exception):
    """CSVの読みとり中にUnicodeDecordErrorが出たらこのエラー"""
    pass


class PostImport(generic.FormView):
    template_name = 'app/import.html'
    success_url = reverse_lazy('app:index')
    form_class = CSVUploadForm
    number_of_columns = 2  # 列の数を定義しておく。各行の列がこれかどうかを判断する

    def save_csv(self, form):
        # csv.readerに渡すため、TextIOWrapperでテキストモードなファイルに変換
        csvfile = io.TextIOWrapper(form.cleaned_data['file'])
        reader = csv.reader(csvfile)
        i = 1  # 1行目でのUnicodeDecodeError対策。for文の初回のnextでエラーになるとiの値がない為
        try:
            # iは、現在の行番号。エラーの際に補足情報として使う
            for i, row in enumerate(reader, 1):
                # 列数が違う場合
                if len(row) != self.number_of_columns:
                    raise InvalidColumnsExcepion('{0}行目が変です。本来の列数: {1}, {0}行目の列数: {2}'.format(i, self.number_of_columns, len(row)))
                
                # 問題なければ、この行は保存する。(実際には、form_validのwithブロック終了後に正式に保存される)
                post, created = Post.objects.get_or_create(pk=row[0])
                post.title = row[1]
                post.save()

        except UnicodeDecodeError:
            raise InvalidSourceExcepion('{}行目でデコードに失敗しました。ファイルのエンコーディングや、正しいCSVファイルか確認ください。'.format(i))

    def form_valid(self, form):
            try:
                # CSVの100行目でエラーがおきたら、前の99行分は保存されないようにする
                with transaction.atomic():
                    self.save_csv(form)
            # 今のところは、この2つのエラーは同様に対処します。
            except InvalidSourceExcepion as e:
                form.add_error('file', e)
                return super().form_invalid(form)
            except InvalidColumnsExcepion as e:
                form.add_error('file', e)
                return super().form_invalid(form)
            else:
                return super().form_valid(form)  # うまくいったので、リダイレクトさせる




画像データの拡張子をcsvにしてアップロードすると、以下のように表示されます。



CSVファイルの、ある行だけ列の数を変えたりすると、以下のように表示されます。
shara 約106日前 2018年3月10日17:40 返信する
いつも拝見させていただいております。
DJANGO初心者ですが、掲載されているCSVのImport機能においてバリデーション機能は含まれているのでしょうか?
モデルで定義されていない方のデータが含まれていたり、そもそも誤ったCSVをImportした場合にデータ登録前にエラーではじく仕組みが必須かと思っています。
なりと 約106日前 2018年3月10日18:28
CSVのimport機能ですが、記事内では特にバリデーション処理をしていません。バリデーション処理をするコードもそのうち追加するかもしれません。
shara 約105日前 2018年3月12日12:51 返信する
ご回答ありがとうございます!
Validation機能ありのCSV Import記事アップデート楽しみにしておりますm(__)m
生徒 約89日前 2018年3月27日16:43 返信する
いつも、このブログを楽しく拝見しています。
djangoは日本語の情報が少ないので、大変勉強になります。
umedyの講座も受講させて頂きました。

自分も、csvアップロードのvalidationの部分を勉強させて頂きたいです。
なりと 約88日前 2018年3月28日20:17
ありがとうございます。
4月中には更新する予定です。
生徒 約88日前 2018年3月28日23:38 返信する
返信有難うございます。
楽しみにしています!
なりと 約76日前 2018年4月10日1:52 返信する
記事を作り直しました。バリデーションもちょっとしてます。