PythonDjangocsv

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

概要

Djangoで、DB ⇔ CSV なやりとりをするサンプルです。 Githubにもソースコードがあるので、クローンしたりして試してください。

デモ

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

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

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

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

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

ソースコード

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'], encoding='utf-8')
        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=cp932だとかutf-8-sig だとかが必要になるかもしれません。デフォルトは'utf-8'です。

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/plaintest/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でラップしています。

TextIOWrapperencoding引数を受け付けるので、encoding='utf-8'等を明示的に指定することが可能です。Windowsだと、このencoding引数が暗黙のうちに'cp932'となることもよくありますので注意です。

        # csv.readerに渡すため、TextIOWrapperでテキストモードなファイルに変換
        csvfile = io.TextIOWrapper(form.cleaned_data['file'], encoding='utf-8')
        reader = csv.reader(csvfile)

for文で、1行ずつ取り出せます。Post()のようにしてモデルインスタンスを作成し、save()で保存します。これは更新にも新規作成にも使えます。

        # 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)

バリデーション

CSVのインポートに関して、いくつかの良くないケースを考えます。

  1. ファイル名にcsvとついていない
  2. ファイルのエンコーディングが違う

欲を言えばもっとあるのですが、手軽なこの2つを実装してみましょう。

forms.py

import csv
import io
from django import forms
from .models import Post


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

    def clean_file(self):
        file = self.cleaned_data['file']

        # ファイル名が.csvかどうかの確認
        if not file.name.endswith('.csv'):
            raise forms.ValidationError('拡張子がcsvのファイルをアップロードしてください')

        # csv.readerに渡すため、TextIOWrapperでテキストモードなファイルに変換
        csv_file = io.TextIOWrapper(file, encoding='utf-8')
        reader = csv.reader(csv_file)

        # 各行から作った保存前のモデルインスタンスを保管するリスト
        self._instances = []
        try:
            for row in reader:
                post = Post(pk=row[0], title=row[1])
                self._instances.append(post)
        except UnicodeDecodeError:
                raise forms.ValidationError('ファイルのエンコーディングや、正しいCSVファイルか確認ください。')

        return file

    def save(self):
        for post in self._instances:
            post.save()

定義したclean_file()メソッドは、fileフィールドのバリデーションを行うメソッドです。clean_フィールド名というメソッドを作るだけで、自動的に呼ばれます。

ファイル名の確認は次の部分です。シンプルですね。

        # ファイル名が.csvかどうかの確認
        if not file.name.endswith('.csv'):
            raise forms.ValidationError('拡張子がcsvのファイルをアップロードしてください')

そして、エンコーディングの確認は次の部分です。単純にエンコーディングが違うか、そもそも全く違うファイル等を渡してもこのエラーが出ます。

        # csv.readerに渡すため、TextIOWrapperでテキストモードなファイルに変換
        csv_file = io.TextIOWrapper(file, encoding='utf-8')
        reader = csv.reader(csv_file)

        # 各行から作った保存前のモデルインスタンスを保管するリスト
        self._instances = []
        try:
            for row in reader:
                post = Post(pk=row[0], title=row[1])
                self._instances.append(post)
        except UnicodeDecodeError:
                raise forms.ValidationError('ファイルのエンコーディングや、正しいCSVファイルか確認ください。')

save()メソッドも定義しました。このsave()メソッドを呼ぶことで、Post(pk=row[0], title=row[1])のように作った各行のデータが、実際に保存されます。

    def save(self):
        for post in self._instances:
            post.save()

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

ちゃんとバリデーションチェックされる

views.py

CSVから取り出したモデルの保存処理をフォームに移したので、シンプルになりました。

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

    def form_valid(self, form):
        form.save()
        return redirect('app:index')

CSVUploadFormを色々なモデルを受け付けれるように汎用的にしたり、列の数が違うなどのバリデーションを追加したり、やろうと思えば色々できそうです。

一括作成or更新

Django、一括作成(bulk_create)で一括更新(bulk_update) で紹介したbulk_create()を使えば、数千や数万件のCSVデータのインポートも行いやすくなります。

しかし、CSVファイル内に新規作成用データと更新用データがごちゃごちゃになっているような今回のケースはちょっと面倒です。

方法は幾つかあるのですが、Django2.2以上であり、データベースがOracleでなく、PostgreSQLの9.5未満でもないならばbulk_create(ignore_conflicts=True)でpk等のユニークな値が既に存在してもエラーを無視する設定にできますし、Django2.2以上ならばbulk_update()での一括更新も使えます。

それを利用した一括作成or一括更新処理ならば、次のように書けます。

    def save(self):
        # 一括作成。更新用のデータは、ignore_conflicts=True によって無視され、エラーにならない
        Post.objects.bulk_create(self._instances, ignore_conflicts=True)

        # 一括更新。上で無視された一括更新用のデータも、ここで更新される
        Post.objects.bulk_update(self._instances, fields=['title'])

考えることも少なく、それなりに汎用的で、パフォーマンス的にもそこまで悪くない処理なのではないかと思います。