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

Twitterでシェア FaceBookでシェア はてなブックマークでシェア

Python - Django
2018年11月22日21:25に更新(約19日前)
2018年11月7日13:11に作成(約34日前)

旧ブログ移行記事です。

概要

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'])
        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'])
        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のデータを保存するならばPost.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ファイルの、ある行だけ列の数を変えたりすると、以下のように表示されます。

Twitterでシェア FaceBookでシェア はてなブックマークでシェア

記事にコメントする