django-pandasを使って、CSV出力をしよう！

# はじめに
こんにちは！
株式会社OZでフルスタックエンジニアをしています、橋本ことぶりぼんと申します。
弊社では、バックエンドの言語としてPython、フレームワークにDjangoを使って実装しております。

今回はタイトルにもある通り、DjangoでCSV機能の実装を解説します。
Pythonの標準モジュールでの実装やpandasを使った実装がありますが、django-pandasという便利なライブラリーがあるのを皆さんはご存知でしょうか。
django-pandasを用いることで、モデルに紐づけて、コード量を少なく簡単にCSVダウンロード・アップロード機能を実装することが可能です。

弊社のプロダクトでCSVダウンロード・アップロード機能を実装することになったのですが、CSV機能の実装経験もなく、かなり手間取りました。
本記事では、皆さんが私と同じような苦しみを味わないように、django-pandasの一般的な使い方や、ユーザーがアップロードしてきたデータにバリデーションをかけてデータの整合性を保つところまで解説したいと思います！
後者に関して、実装の記事が全くなかったので、弊社独自で実装することにしました。

それではいきましょう！！

# 準備
## レポジトリ作成
使用するライブラリーは以下の通りです。

- Python         3.8.2
- Django         3.0.0
- pipenv         不明(version 2018.11.26)
- django-pandas  0.6.2（執筆時点で最新）

以下、レポジトリ準備のため細かい説明は端折ります。
pipenvを使って環境分離とライブラリー管理をしており、pipenvに関する説明は別の記事に譲ります。

```shell
$ mkdir csv_func
$ cd csv_func
$ pipenv --python 3.8 # python3.8のバージョンでpipenvのプロジェクトを作成している。事前にpipenvをpipでインストールする必要あり。
$ pipenv shell        # pipでも行っていた、仮想環境を立ち上げるコマンド。
$ pipenv install django==3.00
$ django-pandas==0.6.2
$ django-admin startproject config .
$ django-admin startapp csv 
```


settings.pyに作成したアプリを登録します（これしないとマイグレーションファイルが作成されないよ）

```python:settings.py
（〜略〜）

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',

    'csv_app', # 追加した箇所
]

（〜略〜）
```

上記でレポジトリの作成は終わりです。
以下からモデル定義とviewの作成に移ります。

## モデル定義から初期データの作成まで
今回は簡単なCSV出力を行いたいので、ユーザーモデルの定義を行います。
以下のような簡単でバリデーションを行いそうなフィールドを定義します。

```python:models.py
class User(models.Model): # Djangoが用意しているUserクラスは継承しない
    last_name = models.CharField(max_length=140, verbose_name="名字")
    first_name = models.CharField(max_length=140, verbose_name="名前")
    postal_code = models.CharField(max_length=140, verbose_name="郵便番号")
    tel_number = models.CharField(max_length=140, verbose_name="電話番号")
```

フロントからデータを入力するのは今回に関して手間なので、adminからユーザーのデータは登録します.

```admin.py
from django.contrib import admin
from csv_app.models import User


admin.site.register(User)
```

ここまできたら、マイグレーションとローカルサーバーを立ち上げてしまいましょう！

```shell
$ python manage.py makemigrations
$ python manage.py migrate
$ python manage.py runserver
```

ローカルサーバーを立ち上げたら、adminユーザーの登録を忘れていることに気づいたので、adminユーザーを作成します。
Usernameやメアド、パスワードは好きなものを指定してください。

```shell
$ python manage.py createsuperuser

（〜略〜）
```


問題なく立ち上がったでしょうか？
筆者は下図の通り、久しぶりに見たDjangoのHello Worldページが表示され、とadmin画面へのログインをできました。
また、先ほど作成したモデルがadminに登録されています。

![Screen Shot 2020-07-22 at 19.02.00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261546/a8b9c0e0-714b-31bc-fe7a-96061f36abf5.png)

![Screen Shot 2020-07-22 at 19.01.11.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261546/06692dfa-4792-c9ad-6c75-a0a71f7ae220.png)



データもadminから適当なものを入力して保存しましょう！
![Screen Shot 2020-07-22 at 19.00.16.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261546/c76f2979-46dc-c1ef-a5f4-3b54e4c7f159.png)

では、viewの定義もさくっと終わらせましょう！

## viewの定義
特に書き記すことはありませんが、クラス関数で実装します。
また、デザインは完全無視します。

```views.py
from django.views.generic import ListView
from csv_app.models import User


class UserListView(ListView):
    template_name = 'users.html'
    model = User
    context_object_name = 'users'
```

```urls.py
from django.contrib import admin
from django.urls import path
from csv_app.views import UserListView

urlpatterns = [
    path('admin/', admin.site.urls),
    path('users/', UserListView.as_view())
]
```

```csv_app/templates/users.html
<table>
  <tr>
    <td>名字</td>
    <td>名前</td>
    <td>電話番号</td>
    <td>郵便番号</td>
  </tr>
  {% for user in users %}
  <tr>
    <td>{{ user.last_name }}</td>
    <td>{{ user.first_name }}</td>
    <td>{{ user.tel_number }}</td>
    <td>{{ user.postal_code }}</td>
  </tr>
  {% endfor %}
</table>
```

これで下図のような簡易的なテーブルが表示されています。

![Screen Shot 2020-07-22 at 19.13.30.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261546/41d43825-bad3-090d-ff20-10f169819786.png)


以上で事前準備は終わりです。
いよいよ本題のCSV機能の実装に移ります！


# CSV機能実装（ダウンロード）
## 実装
早速、CSVダウンロード機能の実装に参りましょう！
aタグのリンクをクリックすると、CSVがダウンロードされる実装とします。
まずは、CSVダウンロード用のリンクを作成しましょう。
コードと画像を提示します。

```csv_app/templates/users.html
<a href="{% url 'users_csv_upload' %}">CSVダウンロード</a> <!-- 追加した箇所 -->

<table>
  <tr>
    <td>名字</td>
    <td>名前</td>
    <td>電話番号</td>
    <td>郵便番号</td>
  </tr>
  {% for user in users %}
  <tr>
    <td>{{ user.last_name }}</td>
    <td>{{ user.first_name }}</td>
    <td>{{ user.tel_number }}</td>
    <td>{{ user.postal_code }}</td>
  </tr>
  {% endfor %}
</table>
```

![Screen Shot 2020-07-30 at 0.00.07.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261546/71b65f09-635b-51fb-60d9-ae33d24f58ee.png)



このリンクをクリックした際にCSVを返すためのviewを定義します。

```urls.py
from django.contrib import admin
from django.urls import path
from csv_app.views import UserListView, UserCsvDownloadView # 追加した箇所

urlpatterns = [
    path('admin/', admin.site.urls),
    path('users/', UserListView.as_view(), name="users"),

    # 追加した箇所
    path('users/csv/download', UserCsvDownloadView.as_view(),
         name="users_csv_download"),
]
```

```views.py
from django_pandas.io import read_frame
from django.http import HttpResponse
from django.views.generic import View, ListView
from csv_app.models import User


class UserListView(ListView):
（〜中略〜）


# 追加したView
class UserCsvDownloadView(View):
    def get(self, request, *args, **kwargs):
        users = User.objects.all()
        df = read_frame(
            users,
            fieldnames=[
                "id", "last_name", "first_name", "postal_code", "tel_number"
            ]
        )

        response = HttpResponse(content_type='text/csv; charset=utf8')
        response['Content-Disposition'] = 'attachment; filename=users.csv'
        df.to_csv(path_or_buf=response, encoding='utf_8_sig', index=None)

        return response
```

実装のあと「CSVダウンロード」リンクをクリックしましょう！
下記の画像のようなCSVファイルが生成されているはずです。

![Screen Shot 2020-07-30 at 0.12.58.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261546/dc2bafad-52e7-b30b-936a-13fed82ff4ee.png)



それでは、簡単ながら解説をいたします。

## 解説
まず、ここで重要な関数は、`read_frame`関数です。
`from django_pandas.io import read_frame`でインポートしています。
この関数の第一引数にqueryset、今回の場合はすべてのUserModelを渡します。
また、名前付き引数で`fieldnames`にUserModelのフィールドを**リスト形式**で指定することで、そのフィールドをCSVの列として表示できるようになります。

レスポンスは、`HttpResponse`で定義します。
今回はCSVダウンロード機能ですので、content_typeに`text/csv`、オプションとして`utf-8`を渡します。
筆者は未確認なのですが、utf-8を指定しないと文字化けする可能性があります。
（文字化けに関しては、`encoding='utf_8_sig'`で強制的に`utf-8`で表示するようにしているので、ここのオプションでは必要ないかもしれません。）

**※追記 Macでは文字化けは起こらないですが、Windowsのexcelでは文字化けが起ってしまいます（強制的にutf-8でexcelを開くわけではない）。対応でき次第、こちらの記事もアップデートいたします。**

`response['Content-Disposition'] = 'attachment; filename=users.csv'`
こちらの1行でファイル名の定義を行なっています。
今回は`users.csv`となっていますが、ダウンロードするファイルの内容に応じて、適切なファイル名に変更してください

`df.to_csv(path_or_buf=response, encoding='utf_8_sig', index=None)`
引数の`path_or_buf`で先ほど定義した`response`を、encodingに`utf_8_sig`を指定します。
そのほかにもオプションがありますが、割愛させていただき、他の記事での説明に譲ります。


以上でCSVダウンロード機能の説明を終わります。
django-pandasはModelのクエリを吐き出すことを非常にシンプルに行ってくれます。

CSVダウンロードは比較的簡単に実装でき、ドキュメントもそこそこ落ちていますが、問題はCSVアップロードです。
アップロード自体はそこまで難しくないのですが、データの整合性を取るためのバリデーションチェックを行っているドキュメントが見当たらなかったので、独自に実装することにしました。
独自の実装ですので、この実装の方がいいよ！という方がおりましたら、教えてきただければ幸いです。
また、以下の説明では`django-pandas`を使わずに、pandasのみを使用していきます。




# CSV機能実装（アップロード）
以下のような簡易的なファイルアップロードフィールドを作成します。
![Screen Shot 2020-07-30 at 0.28.16.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261546/6f5abc38-c70f-3c16-405f-96e1765a479d.png)



## 実装

まずは、コードとアップロード後のデータを提示します

```urls.py
from django.contrib import admin
from django.urls import path
from csv_app.views import UserListView, UserCsvDownloadView, UserCsvUploadView

urlpatterns = [
    path('admin/', admin.site.urls),
    path('users/', UserListView.as_view(), name="users"),
    path('users/csv/download', UserCsvDownloadView.as_view(),
         name="users_csv_download"),

    # 今回追加した箇所
    path('users/csv/upload', UserCsvUploadView.as_view(),
         name="users_csv_upload"),
]
```

```views.py
from django_pandas.io import read_frame
from django.urls import reverse_lazy
from django.http import HttpResponse, HttpResponseRedirect
from django.views.generic import View, ListView

from csv_app.models import User
from csv_app.forms import UserCsvUploadForm


class UserListView(ListView):
    template_name = 'users.html'
    model = User
    context_object_name = 'users'

    def get_context_data(self, **kw):
        context = super().get_context_data(**kw)
        csv_upload_form = UserCsvUploadForm()

        context.update({
            'csv_upload_form': csv_upload_form
        })
        return context

（〜中略〜）

# 追加したView
class UserCsvUploadView(View):
    success_url = reverse_lazy('users')

    def post(self, request, *args, **kwargs):
        form = UserCsvUploadForm(request.POST, files=request.FILES)
        form.save()
        return HttpResponseRedirect(self.success_url)
```

```forms.py
import pandas as pd
from django import forms
from csv_app.models import User


class UserCsvUploadForm(forms.Form):

    # formのフィールド定義
    file = forms.FileField(label='CSVファイル')

    def save(self):
        df = pd.read_csv(self['file'].value(), sep=',', encoding='utf8')
        df = df.fillna("")
        row_iter = df.iterrows()

        for row in row_iter:
            row = row[1]  # tupleの中でデータが入っているものだけを取り出している
            pk = row.get("id")
            user = User.objects.get(pk=pk)
            user.last_name = row.get("last_name")
            user.first_name = row.get("first_name")
            user.postal_code = row.get("postal_code")
            user.tel_number = row.get("tel_number")
            user.save()
```

```users.html
<a href="{% url 'users_csv_upload' %}">CSVダウンロード</a>
<form method="POST" action="{% url 'users_csv_upload' %}" enctype="multipart/form-data">
  {% csrf_token %}
  {{ csv_upload_form.file }}
  <button type="submit">アップロード</button>
</form>

<table>
  <tr>
    <td>名字</td>

（〜略〜）
```

郵便番号をすべて`2`に変えたデータをアップロードします。
![Screen Shot 2020-07-30 at 0.25.28.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261546/76c1d1d6-8737-82c0-7062-ca89da0ec945.png)


アップロード後
![Screen Shot 2020-07-30 at 0.24.47.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261546/5dd3c15f-5d1d-9517-c836-9f0629bd85f3.png)
データが変わってますね！
解説に移ります。



## 解説

urls.pyやユーザー一覧画面でアップロードフォームを表示している箇所に関しての説明は、割愛します。
get_context_dataを使って、UserCsvUploadFormを表示しています。

アップロードボタンを押して以後のDB更新処理に関して説明します。
`UserCsvUploadView`をご覧ください。

ここでは、postメソッドでrequestを受け取り、`UserCsvUploadForm`にフロントから受け取ったデータを渡しています。
UserCsvUploadFormのインスタンスメソッドである`save`が、実施にUserモデルのデータ更新を担っています。

UserCsvUploadFormでは、まずフィールドとして、`file = forms.FileField(label='CSVファイル')`を定義しています。
これにより、フロントでファイルアップロードができるフィールドを表示し、受け取ったデータをfileに簡単に格納することができます。
ここで最も肝となるのが`saveメソッド`です。

saveメソッドでは、pandasの`read_csvメソッド`を用いて、csvの読み取りを行っています。
第一引数にcsvのデータを（selfの`file`のvalueにcsvのデータが入っている）、sec引数にはコンマ、encodingはutf8を指定します。
`df = df.fillna("")`では、CSVのセルが空行だった場合にどういうデータを入れるかを定義します。
今回は、空のセルがないですが、空のセルの場合は空文字列を入れるようにします（空文字じゃない場合、予期せぬバグに出会ったような，，，，）。

`row_iter = df.iterrows()`でfor文で１レコードずつ回すことができるように、イテレーションに変換しています。

row_iterをfor文で回した1行1行には、CSVの1行1行が入ってきます。
始めにリストの2番目を使用する（ここに実際のデータが入っている）ので取り出します。
ここからは、最初に指定したモデルのカラムで`row`から実際のデータを引っ張ってきます。
このように実装することで、CSVのアップロード機能を作成することができます！

以上の説明では単にCSVアップロードをするだけでしたが、私が最も肝心だと思う、CSVのデータの整合性を保つためのバリデーションを実装していきます。
かなり長い説明になりましたが、次で最後の説明となります。
お付き合いいただければ幸いです。



# CSVアップロード機能のバリデーション

## 実装
この項目でも、最初に実装と画像を提示します。

また、電話番号にバリデーションをかけます。
市外局番がどこかを判別したいという要求が多いため、*012-3456-7890*のようなバリデーションをかけ、このフォーマットに合わないものはエラーで突き返します。


```forms.py
import re
import pandas as pd
from django import forms
from csv_app.models import User


class UserForm(forms.ModelForm):

    class Meta:
        model = User
        fields = "__all__"

    # 今回追加したメソッド
    def clean_tel_number(self):
        field_name = 'tel_number'
        data = self.cleaned_data.get(field_name)

        pattern = r'\d+-\d+-\d+' # 012-3456-7890のような電話番号で入力してもらう
        result = re.match(pattern, data)

        if result is None:
            return self.add_error(field_name,  "入力された形式が違います。")

        return data


class UserCsvUploadForm(forms.Form):

    # formのフィールド定義
    file = forms.FileField(label='CSVファイル')

    def save(self):
        df = pd.read_csv(self['file'].value(), sep=',', encoding='utf8')
        df = df.fillna("")
        row_iter = df.iterrows()
        errors = []

        for row in row_iter:
            row = row[1]  # tupleの中でデータが入っているものだけを取り出している
            pk = row.get("id")
            user = User.objects.get(pk=pk)

            user_data = {
                "last_name": row.get("last_name"),
                "first_name": row.get("first_name"),![Screen Shot 2020-08-04 at 0.17.09.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261546/81119eb2-4eec-0d41-66e5-466255249cf8.png)

                "postal_code": row.get("postal_code"),
                "tel_number": row.get("tel_number"),
            }

            # 以下追加した箇所
            user_form = UserForm(data=user_data, instance=user)

            if user_form.is_valid():
                user_form.save()
            else:
                errors.append(str(pk) + "にエラーがあります。")

        return errors
```

```views.py
（〜略〜）

class UserCsvUploadView(View):
    success_url = reverse_lazy('users')

    def post(self, request, *args, **kwargs):
        form = UserCsvUploadForm(request.POST, files=request.FILES)

        # 以下実装を変えた箇所
        errors = form.save()
        if errors:
            messages.success(request, "エラーがあります")
        return HttpResponseRedirect(self.success_url)
```

以下のデータのCSVをアップロードします。
すると、以下のようなエラーが出力され、データが変わっていないことが確認できます。
![Screen Shot 2020-08-04 at 0.17.09.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261546/36eb4dfa-fc71-1f84-e2e7-cb72bc61ac5f.png)

![Screen Shot 2020-08-04 at 0.18.27.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261546/d01c018b-2b64-169c-4f92-22113d8daf85.png)

また、正常系は以下の通りです。
![Screen Shot 2020-08-04 at 0.26.31.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261546/0e60b1f8-cca2-f308-6ff9-5b73497f765f.png)

![Screen Shot 2020-08-04 at 0.26.07.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261546/fa0cc7a3-e319-130c-5a86-0fe01a0b1bba.png)



## 解説
まず、UserFormの簡単な解説です。
`clean_tel_number`メソッドを定義することで、tel_numberフィールドにバリデーションをかけるようにします。
メソッド内容は、正規表現を使って*012-3456-7890*のような形式かどうかをチェックしています。

このUserFormを使って、CSVのデータの1行1行に対してバリデーションをかけていきます。
DjangoのCreateViewやUpdateViewを普段使っている方からすると、少し見慣れない使い方かもしれませんね。

はじめに`user_data`をDict型で定義します。
keyはフィールド名と同じ名前を指定します。
valueにCSVのデータからカラムを指定して引っ張ってきます。

UserFormクラスの`data`に先ほど定義したuser_dataを、instanceに前の章で定義したuser（Userモデルのインスタンス）を渡します。
UserFormのインスタンスに対して、is_validでインスタンスのデータ形式がバリデーションに則っているかチェックします。
則っている場合はそのままsaveを、則っていない場合、予め定義したerrorsリストに、どのPKのエラーかを渡します。

今回はerrorsリストが空でなければエラーメッセージを表示する実装にしていますが、errorsリストを使うことでユーザーにどのレコードがデータ形式に誤りがあるかを伝えることができます。
以上で実装は終わりです。
お疲れ様でした！！

## 懸念点
前までの章ではあげることができなかった懸念点をいくつかご紹介します。
私の実装を参考にしつつ、以下の懸念点を解消することで、より良いプロダクトができそうですね。

#### 1. 一部データ形式が正しく、一部データ形式に誤りがある場合、DBに保存されるレコードと保存されないレコードが存在してしまう。
上記の実装では、is_validがTrueの場合、Formクラスのインスタンスのsaveメソッドを呼び出します。
つまり、1つのレコードにデータ形式が正しい場合、他のレコードを考慮せずにDBに保存されてしまうのです。
saveメソッドのcommitをFalseにして、全レコードにエラーがなかったら、返ってきたモデルインスタンスをbulk_createで一括更新することで解消できそうです。

#### 2. エンコーディングの問題により、OSもしくは開くアプリケーションにより、文字化けが発生する
今回のCSV機能により、文字コードの重要性がかなり深まりました。
私が今まで実装した中で、文字コードを意識した実装をすることはありませんでした。
しかし、WindowsのExcelで今回作成したCSVのダウンロードデータを開くと、世にもおぞましい文字化けに遭遇しました。
Windowsでの文字化けを防ぐためにエンコードを変更するも、今度はMacで文字化けが発生し、しまいにはアップロードも文字コードのせいでエラーが発生するという、地獄ループに陥りました。
なんとか他の方の記事を参考にしつつ弊社のCSV機能はリリースできたのですが、文字コードの詳細は理解しないままの実装となりました。

また別の記事でご紹介することになると思いますが、CSV機能に限らず文字コードの扱いにはみなさん気をつけてください。


代表的な懸念点は以上の２点でしょうか。
他にも、モデルのフィールド名ではなく、verbose_nameで表示する方法やエラーの表示など、説明し足りないことが山ほどあります。
新しい情報が入り次第、順次この記事を更新していくとともに、反響が多かったり多くの方にこの記事を見てもらったりした場合、第二段を作成することも考えております！

拙い説明ではありますが、これにてdjango-pandasを用いたCSV機能の実装解説を終わります。
皆さんのよきCSVライフを！！


# 終わりに
株式会社OZでは一緒に開発するメンバーを募集しております.
現状、フルコミットのメンバーは私一人しかおらず、エンジニアを絶賛募集中です！
Djangoを使ってプロダクト開発をしておりますが、サイドプロジェクトでは、Node, Reactを使っており、今後はRustやネイティブアプリなどに挑戦していこうと思っております。
エンジニアとして、様々なことに挑戦できる環境でありますので、興味がある方はぜひご連絡していただければうれしいです！

# 参考資料
https://note.nkmk.me/python-pandas-to-csv/
https://sinyblog.com/django/django-pandas/
https://blog.fantom.co.jp/2019/06/06/set-the-character-code-of-the-downloaded-csv-to-shift-jis-by-django/
https://qiita.com/y4m3/items/674423b596284bbc7cf7