DjangoでCSVのインポート,エクスポート
どうも,筆者です.
久しぶりの更新になる.最近,Djangoを使って,Webサイトを構築している. この時,データをDBに格納することになるが,この作業をGUIを用いて実施するのは,シンドイ.
ということで,CSVからデータを読み取り,DBに格納することにした.また,DBに保存されたデータをCSVに書き出せるようにもした.
ちなみに,DBとかDjangoとか,サーバサイド(?)の話は,よく分かっていない.このため,以下のサイトを参考にした(ほとんどがNaritoさんのブログの内容).
また,MVC(デザインパターンの一種らしい)の場合,viewに処理を書かずに,modelに処理を書いた方が良いらしいので,その辺も考えながら書いてみた(あっているか分からない).
今回の対象
ここでは,情報処理技術者試験の午前の問題をWeb上で解いて,採点できるようにする(エンベデッドシステムスペシャリストの問題が見当たらないため). この時,データを入力していくのは手間であるため,PDFファイルを参照する形式とする.
内容
以下の順に説明する.
- setting.pyの変更点
- URLの定義
- Modelの定義
- Formの作成
- Viewの設定
- Filterの定義
- 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 %}
一部,切り出しながら作成しているため,どこかでエラーが生じるかもしれない.