作業中のメモ

よく「計算機」を使って作業をする.知らなかったことを中心にまとめるつもり.

DjangoでCSVのインポート,エクスポート

どうも,筆者です.

久しぶりの更新になる.最近,Djangoを使って,Webサイトを構築している. この時,データをDBに格納することになるが,この作業をGUIを用いて実施するのは,シンドイ.

ということで,CSVからデータを読み取り,DBに格納することにした.また,DBに保存されたデータをCSVに書き出せるようにもした.

ちなみに,DBとかDjangoとか,サーバサイド(?)の話は,よく分かっていない.このため,以下のサイトを参考にした(ほとんどがNaritoさんのブログの内容).

narito.ninja

ethanshearer.com

また,MVCデザインパターンの一種らしい)の場合,viewに処理を書かずに,modelに処理を書いた方が良いらしいので,その辺も考えながら書いてみた(あっているか分からない).

kyoro353.hatenablog.com

今回の対象

ここでは,情報処理技術者試験の午前の問題をWeb上で解いて,採点できるようにする(エンベデッドシステムスペシャリストの問題が見当たらないため). この時,データを入力していくのは手間であるため,PDFファイルを参照する形式とする.

内容

以下の順に説明する.

  1. setting.pyの変更点
  2. URLの定義
  3. Modelの定義
  4. Formの作成
  5. Viewの設定
  6. Filterの定義
  7. Templateの作成

setting.pyの変更点

setting.pyの変更点を示しておく

# 中略...

TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'DIRS': [os.path.join(BASE_DIR, 'templates')], # 変更:[] → [os.path.join(BASE_DIR, 'templates')]
        'APP_DIRS': True,
        'OPTIONS': {
            'context_processors': [
                'django.template.context_processors.debug',
                'django.template.context_processors.request',
                'django.contrib.auth.context_processors.auth',
                'django.contrib.messages.context_processors.messages',
            ],
            # 以下を追加
            'libraries': {
                'ipa_exam_filter': 'custom_templatetags.ipa_exam_filter',
            },
            # ここまで
        },
    },
]

# 中略...

# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/3.0/howto/static-files/

STATIC_URL = '/static/'
MEDIA_ROOT = '/media/' # 追加

URLの定義

URLを定義する.

from django.urls import path
from . import views

app_name = 'ipa_exam'

urlpatterns = [
    path('', views.Index.as_view(), name='index'),
    path('import_csv/', views.ImportCsv.as_view(), name='import_csv'),
    path('export_csv/', views.export_csv, name='export_csv'),
]

Modelの定義

モデルを定義する.今後,試験区分を追加したいことも考え,以下のような構成にする.

  • 試験区分(カテゴリー)
  • 試験時間(スケジュール)
  • 問題の解答

また,CSVファイルでファイルの読み込みと書き込みのため,その処理もモデルに書く.

from django.conf import settings
from django.db import models
from django.core.validators import MaxValueValidator, MinValueValidator
import os
import csv
import io

# エラー処理用のクラス定義
class InvalidColumnsExcepion(Exception):
    pass
class InvalidSourceExcepion(Exception):
    pass

# モデル生成用(CSVから読み込んだデータを利用)
def _create_model(target_model, csv_file):
    try:
        with io.TextIOWrapper(csv_file) as fin:
            # UnicodeDecodeErrorは,readerが呼ばれたときに発生するはず(うろ覚え).
            # このため,最初に例外が失敗した際の対策として,idx = 1とする
            idx = 1
            reader = csv.reader(fin)

            for idx, row in enumerate(reader, 1):
                row = [val for val in row if val != '']
                # skip comment and blank
                if row[0][0] == '#' or len(row) == 0:
                    continue
                # create data and check error
                if target_model.create_data(row):
                    raise InvalidColumnsExcepion('Error: Format is invalid in line {}'.format(idx))
    except UnicodeDecodeError:
        raise InvalidSourceExcepion('Error: Failed to decode in line {}'.format(idx))
    except Exception as e:
        raise Exception(e)

# データ書き出し用
def get_response_from_model(response):
    writer = csv.writer(response)
    models = {'Category': IpaExamCategory, 'Schedule': IpaExamSchedule, 'Answer': IpaExamAnswer}

    for model_name, target_model in models.items():
        # model name
        writer.writerow(['# model name', '{}'.format(model_name)])
        for instance in target_model.objects.all():
            writer.writerow(instance.get_data())
        # blank
        writer.writerow([])

    return response

# 試験区分(カテゴリー)
class IpaExamCategory(models.Model):
    name = models.CharField(max_length=255, blank=False)
    root_path = models.CharField(max_length=255, blank=False, default='')

    def __str__(self):
        ret = self.name

        return ret

    # データ生成用のクラスメソッド
    # データの内容の内訳については,モデルが知っているものとし,
    # 参照順などは,モデルに押し込むことにした.
    @classmethod
    def create_data(cls, data):
        err = True

        if len(data) == 2:
            err = False
            model, _ = cls.objects.get_or_create(
                name=data[0],
                root_path=data[1]
            )
            model.save()

        return err

    # Viewから呼び出す用のクラスメソッド
    @classmethod
    def save_model_from_csv_file(cls, csv_file):
        try:
            _create_model(cls, csv_file)
        except (InvalidSourceExcepion, InvalidColumnsExcepion, Exception) as e:
            raise Exception(e)

    # csvに書き出す用(生成したインスタンスの内容を返却)
    def get_data(self):
        ret = [self.pk, self.name, self.root_path]

        return ret

class IpaExamSchedule(models.Model):
    SELECT_TYPES = ((1, '午前I'), (2, '午前II'))
    category = models.ForeignKey(IpaExamCategory, on_delete=models.CASCADE)
    name = models.CharField(max_length=255)
    types = models.IntegerField(choices=SELECT_TYPES, default=None)
    num_question = models.IntegerField(choices=((25, '25'), (30, '30')), default=None)
    filename = models.CharField(max_length=255, blank=False, default='')

    def __str__(self):
        ret = '{} {} {}'.format(self.category.name, self.name, self.get_types_display())

        return ret

    @classmethod
    def create_data(cls, data):
        err = True

        if len(data) == 5:
            err = False
            model, _ = cls.objects.get_or_create(
                category=IpaExamCategory.objects.get(pk=int(data[0])),
                name=data[1],
                types=int(data[2]),
                num_question=int(data[3]),
                filename=data[4],
            )
            model.save()

        return err

    @classmethod
    def save_model_from_csv_file(cls, csv_file):
        try:
            _create_model(cls, csv_file)
        except (InvalidSourceExcepion, InvalidColumnsExcepion, Exception) as e:
            raise Exception(e)

    def get_data(self):
        ret = [self.pk, self.category.id, self.name, self.types, self.num_question, self.filename]

        return ret

    def get_filepath(self):
        media_dir = getattr(settings, 'MEDIA_ROOT')
        abs_path = os.path.join(media_dir, self.category.root_path, self.filename)

        return abs_path

class IpaExamAnswer(models.Model):
    SELECT_ITEMS = ((1, 'ア'), (2, 'イ'), (3, 'ウ'), (4, 'エ'))
    schedule = models.ForeignKey(IpaExamSchedule, on_delete=models.CASCADE)
    question_id = models.IntegerField(validators=[MinValueValidator(1), MaxValueValidator(30)])
    answer = models.IntegerField(choices=SELECT_ITEMS, default=1)

    def __str__(self):
        ret = '{} No.{}'.format(self.schedule, self.question_id)

        return ret

    @classmethod
    def create_data(cls, data):
        err = True

        if len(data) == 3:
            err = False
            model, _ = cls.objects.get_or_create(
                schedule=IpaExamSchedule.objects.get(pk=int(data[0])),
                question_id=int(data[1]),
                answer=int(data[2]),
            )
            model.save()

        return err

    @classmethod
    def save_model_from_csv_file(cls, csv_file):
        try:
            _create_model(cls, csv_file)
        except (InvalidSourceExcepion, InvalidColumnsExcepion, Exception) as e:
            raise Exception(e)

    def get_data(self):
        ret = [self.pk, self.schedule.id, self.question_id, self.answer]

        return ret

Formの作成

フォームを作成する.また,フォームのメソッドとして以下を用意する. 1. 拡張子が.csvになっているかの確認(clean_csv_file) 1. バリデーション済みのデータの取得(get_cleaned_data)

from django import forms
from .models import IpaExamCategory, IpaExamSchedule, IpaExamAnswer

class UploadCsvForm(forms.Form):
    # 登録対象のモデルは選択する形式
    MODEL_NAMES = ((0, 'Category'), (1, 'Schedule'), (2, 'Answer'))
    # Formに対応するモデルも紐づけておく
    MODELS = {0: IpaExamCategory, 1: IpaExamSchedule, 2: IpaExamAnswer}
    csv_file = forms.FileField(
        label='csv file',
        help_text='extention is .csv only',
        required=True,
    )
    target_model = forms.ChoiceField(
        label='model name',
        widget=forms.Select,
        choices=MODEL_NAMES,
        help_text='select using model',
        required=True,
    )

    # バリデーション用
    def clean_csv_file(self):
        csv_file = self.cleaned_data['csv_file']

        if not csv_file.name.endswith('.csv'):
            raise forms.ValidationError('Error: extension must be .csv')
        return csv_file

    # データ取得用
    def get_cleaned_data(self):
        csv_file = self.cleaned_data['csv_file']
        target_model = self.MODELS[int(self.cleaned_data['target_model'])]

        return csv_file, target_model

Viewの設定

ビューの設定を行う.

from django.urls import reverse_lazy
from django.http import HttpResponse
from django.views.generic import ListView
from django.contrib import messages
from django.db import transaction
from .models import IpaExamCategory, IpaExamSchedule, get_response_from_model
from .forms import UploadCsvForm

# Create your views here.

class Index(ListView):
    template_name = 'ipa_exam/index.html'
    model = IpaExamSchedule
    context_object_name = 'ipa_exam_schedule'

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context['ipa_exam_category'] = IpaExamCategory.objects.all()

        return context

class ImportCsv(FormView):
    template_name = 'ipa_exam/import_csv.html'
    form_class = UploadCsvForm
    success_url = reverse_lazy('ipa_exam:index')

    def form_valid(self, form):
        try:
            # 途中で失敗した時は,保存したデータを破棄する(データベースの原子性)
            with transaction.atomic():
                # =================================================
                # Viewは,どのモデルに対し処理を行うかを意識しなくてよい
                # =================================================
                # get data
                csv_file, target_model = form.get_cleaned_data()
                # save data
                target_model.save_model_from_csv_file(csv_file)
        except Exception as e:
            messages.error(self.request, e)
            response = super().form_invalid(form)
        else:
            response = super().form_valid(form)
        return response

def export_csv(request):
    response = HttpResponse(content_type='text/csv')
    response['Content-Disposition'] = 'attachment; filename="models.csv"'
    # モデルで定義されているデータをすべて保存
    response = get_response_from_model(response)

    return response

Filterの定義

custom_templatetags直下に,以下の内容でipa_exam_filter.pyを作成する.

from django import template

register = template.Library()

@register.filter
def filtered_schedule(schedule, category_name):
    return schedule.filter(category__name=category_name)

Templateの作成

Templateを作成する.以下では,templateからの相対パスで示す.

base.html

{% load static %}
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    {# Bootstrap の CSS を読み込み #}
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.0/css/bootstrap.min.css"
          integrity="sha384-9gVQ4dYFwwWSjIDZnLEWnxCjeSWFphJiwGPXr1jddIhOegiu1FwO5qRGvFXOdJZ4" crossorigin="anonymous">
    {# jQuery の読み込み #}
    <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>
    {# Bootstrap の JS 読み込み(jQuery よりも後に読み込む) #}
    <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.1.0/js/bootstrap.min.js"
            integrity="sha384-uefMccjFJAIv6A+rW+L4AHf99KvxDjWSu1z9VI8SKNVmz4sk7buKt/6v9KI65qnm"
            crossorigin="anonymous"></script>
    <title>{% block title %}{% endblock %}</title>
    {% block style %}
    {% endblock %}
    {% block headerjs %}
    {% endblock %}
</head>
<body>
    <div class="container">
        {% block content %}
        {% endblock %}
    </div>
    {% block bodyjs %}
    {% endblock %}
</body>
</html>

ipa_exam/index.html

{% extends 'base.html' %}
{% load ipa_exam_filter %}

{% block title %}
情報処理技術者試験
{% endblock %}

{% block content %}
<div class="row justify-content-center">
    <div class="col-12">
        <h3 class="h3 mt-3">情報処理技術者試験</h3>
    </div>
</div>
<div class="row justify-content-center">
    <div class="col-12">
        <h4 class="h4 mt-1">CSV 読み込み・書き出し</h4>
        <div class="row">
            <div class="col-6">
                <a href="{% url 'ipa_exam:import_csv' %}" class="btn btn-primary btn-block">Import CSV</a>
            </div>
            <div class="col-6">
                <a href="{% url 'ipa_exam:export_csv' %}" class="btn btn-success btn-block">Export CSV</a>
            </div>
        </div>
        <hr>
    </div>
</div>
<div class="row justify-content-center">
    <div class="col-12">
        {% for category in ipa_exam_category %}
        <div class="row">
            <div class="col-12">
                <h4 class="h4 mt-1">{{ category.name }}</h4>

                <table class="table table-striped">
                    <thead>
                        <tr>
                            <th scope="col">試験時期</th>
                            <th>午前I・午前II</th>
                            <th>リンク</th>
                        </tr>
                    </thead>
                    <tbody>
                        {% for schedule in ipa_exam_schedule|filtered_schedule:category.name %}
                        <tr>
                            <td scope="row">{{ schedule.name }}</td>
                            <td>{{ schedule.get_types_display }}</td>
                            {# 取りあえず,PDFファイルの絶対パスを表示 #}
                            <td>{{ schedule.get_filepath }}-</td>
                        </tr>
                        {% endfor %}
                    </tbody>
                </table>
            </div>
        </div>
        {% endfor %}
    </div>
</div>
{% endblock %}

ipa_exam/import_csv.html

{% extends 'base.html' %}

{% block title %}
情報処理技術者試験
{% endblock %}

{% block content %}
<div class="row justify-content-center">
    <div class="col-12">
        <h3 class="h3 mt-3">Import CSV</h3>
    </div>
</div>

<div class="row justify-content-center">
    <div class="col-12">
        <ul class="messages">
        {% for msg in messages %}
            <li {% if msg.tags %} class="{{ msg.tags }}" {% endif %}>
                {% if msg.level == DEFAULT_MESSAGE_LEVELS.ERROR %}
                    <font color="#f00">{{ msg }}</font>
                {% else %}
                    {{ msg }}
                {% endif %}
            </li>
        {% endfor %}
        </ul>
    </div>
</div>
<div class="row justify-content-center">
    <div class="col-12">
        <form action="" method="post" enctype="multipart/form-data">
            <div class="row">
                <div class="col-12">
                    {% csrf_token %}
                    <p>Select csv file to upload model data</p>
                    <div class="form-group">
                        <table class="table table-striped">
                            <thead>
                                <tr>
                                    <th scope="col">項目</th>
                                    <th>入力フォーム</th>
                                    <th>help text</th>
                                </tr>
                            </thead>
                            <tbody>
                                {% for field in form %}
                                <tr>
                                    <td scope="row">{{ field.label_tag }}</td>
                                    <td>
                                        {{ field }}
                                    </td>
                                    <td>
                                        {% if field.help_text %}
                                            {{ field.help_text }}
                                        {% else %}
                                            -
                                        {% endif %}
                                    </td>
                                </tr>
                                {% endfor %}
                            </tbody>
                        </table>
                    </div>
                </div>
            </div>
            <div class="row">
                <div class="col-6">
                    <button type="submit" class="btn btn-primary btn-block">送信</button>
                </div>
                <div class="col-6">
                    <a href="{% url 'ipa_exam:index' %}" class="btn btn-secondary btn-block">戻る</a>
                </div>
            </div>
        </form>
    </div>
</div>
{% endblock %}

一部,切り出しながら作成しているため,どこかでエラーが生じるかもしれない.