【第7回】Responderを使ってDjangoチュートリアルをやってみた【adminページ改良編】
IT技術
前回の記事の「【第6回】Responderを使ってDjangoチュートリアルをやってみた【静的ファイル管理編】」の続きです。
今回も、Responder(レスポンダー)を使って「Djangoのチュートリアル」をやってみたいと思います。
Django のチュートリアル「はじめての Django アプリ作成」を、Responderで追う形になりますので、多少内容が異なる部分がありますが、成果物はできるだけ同じモノになるよう作る予定です。
【はじめての Django アプリ作成】
https://docs.djangoproject.com/ja/2.2/intro/
第1回はこちら
adminフォームのカスタマイズ
ついに、Djangoチュートリアルの最後です。
チュートリアルの最後では、管理者(admin)ページのカスタマイズをしましょう。
現在、Pollsアプリケーションは正常に動作していますが、どうも「質問の追加」が面倒な印象です。
質問(Question)を追加する際に、選択肢(Choice)も動的に追加できるようにしたほうが、より良いアプリケーションとなりそうです。
しかし、Djangoチュートリアルと同じような形にするには、ResponderではDjangoほど簡単にはいきません。
まずは雛形を作る
今までフィールドの追加は、質問と選択肢それぞれについて作成しました。
しかし、すべて「質問の追加機能(Add_question)」に統合したいと思います。
また、ついでに未来の質問を設定できるようにフォームを修正しましょう。
Questionの追加フォームの修正
まず、AddQuestionクラスのGETメソッドで、現在の日付をビューに渡すようにします。
1@api.route('/add_Question')
2class AddQuestion:
3 async def on_get(self, req, resp):
4 """
5 getの場合は追加専用ページを表示させる。
6 """
7 authorized(req, resp, api)
8 date = datetime.now()
9
10 resp.content = api.template('add_question.html', date=date)
次に、ビューも修正します。
(ついでにデザインも少し変更してみました)
1{% extends "layout.html" %}
2{% block content %}
3
4<br><br>
5<h3>Add Question</h3>
6<br>
7<a class="btn-sm btn-primary" href="/admin_top">Back to Admin top</a>
8<br>
9<br>
10<br>
11<form action="/add_Question" method="post">
12 <h4 class="bg-info text-white" style="padding: 15px">QUESTION</h4>
13 <br>
14 <div class="row">
15 <div class="col-md-5"><p>question_text</p></div>
16 <div class="col-md-7"><input type="text" name="question_text"></div>
17 <br>
18 </div>
19 <hr>
20 <div class="row">
21 <div class="col-md-5">
22 <p>Date</p>
23 </div>
24 <div class="col-md-7">
25 <input type="text" name="date" value="{{date['year']}}-{{date['month']}}-{{date['day']}}">
26 </div>
27 <div class="col-md-5">
28 <p>Time</p>
29 </div>
30 <div class="col-md-7">
31 <input type="text" name="time" value="{{date['hour']}}:{{date['minute']}}:{{date['second']}}">
32 </div>
33
34 </div>
35 {% for error_message in error_messages %}
36 <p class="text-danger">{{error_message}}</p>
37 {% endfor %}
38 <input class="btn btn-warning" type="submit" name="submit" value="Add Question">
39</form>
40<br>
41<br>
42
43
44{% endblock %}
確認
良い感じですね。
しかし、まだ、POSTメソッドの処理は修正していないのでうまく働きません。
選択肢フォームの追加
次に、質問に紐づける選択肢を登録するフォームを作成しましょう!
とりあえず、選択肢のフォームを2つ作成してみます。
1{% extends "layout.html" %}
2{% block content %}
3
4<br><br>
5<h3>Add Question</h3>
6<br>
7<a class="btn-sm btn-primary" href="/admin_top">Back to Admin top</a>
8<br>
9<br>
10<br>
11<form action="/add_Question" method="post">
12 <h4 class="bg-info text-white" style="padding: 15px">QUESTION</h4>
13
14 <!-- 省略 -->
15
16 </div>
17
18 <br>
19 <h4 class="bg-info text-white" style="padding: 15px">CHOICES</h4>
20
21 <div id="add_choice">
22
23 <p style="background:#cccccc; padding: 10px">CHOICE #1</p>
24 <div class="row">
25 <div class="col-md-5">choice_text</div>
26 <div class="col-md-7"><input type="text" name="choices[]"></div>
27 </div>
28 <br>
29 <div class="row">
30 <div class="col-md-5">votes</div>
31 <div class="col-md-7"><input type="text" name="votes[]" value="0"></div>
32 </div>
33
34 <hr>
35 <p style="background:#cccccc; padding: 10px">CHOICE #2</p>
36 <div class="row">
37 <div class="col-md-5">choice_text</div>
38 <div class="col-md-7"><input type="text" name="choices[]"></div>
39 </div>
40 <br>
41 <div class="row">
42 <div class="col-md-5">votes</div>
43 <div class="col-md-7"><input type="text" name="votes[]" value="0"></div>
44 </div>
45
46 </div>
47
48 <br>
49 <hr>
50 <a class="btn btn-warning btn-sm" onclick="add()">+ Add another Choice</a>
51 <br>
52 <hr>
53
54 <input class="btn btn-warning" type="submit" name="submit" value="Add Question">
55</form>
56<br>
57<br>
58
59{% endblock %}
下部には「Add another Choice」ボタンを設置し、3個以上の選択肢を登録できるようにします。
しかし、まだ add()関数 を実装していないので、ボタンを押しても何もおこりません。
JavaScriptで実装
では、早速、JavaScript で実装しましょう。
あらかじめ、<div id="add_choice"> で追加する部分にID付けしておきました。
このIDをJavaScript側で見つけて、同じようなフォームを追加していきます。
したがって以下のように実装できます。
1<script>
2 var num = 3;
3 function add()
4 {
5 var div_element = document.createElement("div");
6 div_element.innerHTML =
7 '<hr><p style="background:#cccccc; padding: 10px">CHOICE #' + num + '</p>'+
8 '<div class="row"><div class="col-md-5">choice_text</div><div class="col-md-7"><input type="text" name="choices[]"></div></div>' +
9 '<br><div class="row"><div class="col-md-5">votes</div><div class="col-md-7"><input type="text" name="votes[]" value="0"></div></div>';
10 var parent_object = document.getElementById("add_choice");
11 parent_object.appendChild(div_element);
12 num++;
13 }
14</script>
このJavaScriptは、先ほどの add_question.html の下部に追加すれば、OKです!
確認
【Add another Choiceボタンを押すと・・・】
【フォームが増えた!】
POST処理の修正 (Add)
それでは、POSTの処理を修正していきます。
やることは大して変わりませんが、ここで初めて配列として受け取るフォームchoices[]とvotes[]があります。
これらは、Responderではget_list() 関数(responder.models.QueryDictクラス)を使って取得します。
実装
したがって、以下のように実装できます。
(ついでにエラー処理も記述)
1 async def on_post(self, req, resp):
2 """
3 postの場合は受け取ったデータをQuestionテーブルに追加する。
4 """
5 data = await req.media()
6 error_messages = list()
7
8 """ エラー処理 """
9 if data.get('question_text') is None:
10 error_messages.append('質問内容が入力されていません。')
11
12 if data.get('date') is None or data.get('time') is None:
13 error_messages.append('公開日時が入力されていません。')
14
15 # 配列として受け取ったフォームはget_list()で取得する
16 choices = data.get_list('choices[]')
17 votes = data.get_list('votes[]')
18
19 if len(choices) == 0 or len(votes) == 0 or len(choices) != len(votes):
20 error_messages.append('選択肢内容に入力されていない項目があります。')
21
22 if len(choices) < 1:
23 error_messages.append('選択肢は2つ以上必要です。')
24
25 # 何かしらエラーがあればリダイレクト
26 if len(error_messages) != 0:
27 resp.content = api.template('add_question.html', error_messages=error_messages, date=datetime.now())
28 return
29
30 """ テーブルにQuestionを追加 """
31 # 公開日時をセパレートしてint型のリストに変換
32 date = [int(d) for d in data.get('date').split('-')]
33 time = [int(t) for t in data.get('time').split(':')]
34
35 # question作成
36 question = Question(data.get('question_text'),
37 datetime(date[0], date[1], date[2],
38 time[0], time[1], time[2]))
39 # question追加
40 db.session.add(question)
41 db.session.commit()
42
43 """ テーブルにChoicesを追加 """
44 # まず外部キーとなるQuestion.idを取得
45 foreign_key = question.id
46 q_choices = list()
47
48 for i, choice in enumerate(choices):
49 # choice作成
50 q_choices.append(
51 Choice(foreign_key, choice, int(votes[i]))
52 )
53
54 # choice追加
55 db.session.add_all(q_choices)
56 db.session.commit()
57
58 db.session.close()
59
60 api.redirect(resp, '/admin_top')
確認
うまくいっていますね!
これで管理者ページはだいぶ使いやすくなりました。
Delete処理の修正
次に、他の処理も少し修正しましょう。
現在は「質問」と「選択肢」の削除はそれぞれで行う必要があります。
しかし、本来ならば「質問」を消せばそれに紐づいた選択肢も消えるべきです。
そうでなければ、データベースにゴミが溜まっていってしまうことになります。
そこで、以下のように修正しましょう。
1@api.route('/delete/{table_name}/{data_id}')
2class DeleteData:
3 async def on_get(self, req, resp, table_name, data_id):
4 # ~~~ 省略 ~~~ #
5
6 async def on_post(self, req, resp, table_name, data_id):
7
8 # データを削除
9 table = Question if table_name == 'question' else Choice
10 record = db.session.query(table).filter(table.id == data_id).first()
11 db.session.delete(record)
12
13 # New! 紐づいた質問も削除
14 if table is Question:
15 choices = db.session.query(Choice).filter(Choice.question == data_id).all()
16 for choice in choices:
17 db.session.delete(choice)
18
19 db.session.commit()
20 db.session.close()
21
22 api.redirect(resp, '/admin_top')
これで、ある質問を消すと、その選択肢も一緒に削除されるようになりました。
Questionの情報を増やす
では、さらに、管理者ページをもっと使いやすくしてみましょう!
そのために、最近の投稿には、視覚的に分かりやすい工夫をしてみます。
1@api.route('/admin_top')
2async def on_session(req, resp):
3 """
4 管理者ページ
5 """
6
7 authorized(req, resp, api)
8
9 # ログインユーザ名を取得
10 auth_user = req.cookies.get('username')
11
12 # データベースから質問一覧と選択肢を全て取得
13 questions = db.session.query(Question).all()
14 choices = db.session.query(Choice).all()
15 db.session.close()
16
17 # 直近の投稿か否か
18 was_recently = [q.was_published_recently() for q in questions]
19
20 # 各データを管理者ページに渡す
21 resp.content = api.template('administrator.html',
22 auth_user=auth_user,
23 questions=questions,
24 choices=choices,
25 was_recently=was_recently,
26 )
このように、「直近の質問」か「否」かを取得し、内包表記でリストにしてビューに渡してみます。
1<h4>Question <a href="/add_Question" class="btn-warning btn-sm">Add</a></h4>
2<table class="table">
3 <thead class="thead-dark">
4 <tr>
5 <th>id</th>
6 <th scope="col">question_text</th>
7 <th scope="col">pub_date</th>
8 <th scope="col">Published recently?</th> <!-- New -->
9 <th scope="col"></th>
10 </tr>
11 </thead>
12 <tbody>
13 {% for question in questions%}
14 <tr>
15 <th scope="row">{{question['id']}}</th>
16 <td>{{question['question_text']}}</td>
17 <td>{{question['pub_date']}}</td>
18 <td> <!-- New -->
19 {% if not was_recently[loop.index-1] %}
20 <div class="text-danger"><strong>×</strong></div>
21 {% else %}
22 <div class="text-success"><strong>○</strong></div>
23 {% endif %}
24
25 </td>
26 <td>
27 <a href="/change/question/{{question['id']}}" class="btn-secondary btn-sm">Change</a>
28 <a href="/delete/question/{{question['id']}}" class="btn-danger btn-sm">Delete</a>
29 </td>
30 </tr>
31 {% endfor %}
32 </tbody>
33</table>
確認
ビューは、上記のように書き加えればOKです。(20行目あたり)
「〇」「×」表示で分かりやすくなりましたね。
上手くいったようです!
検索機能をつける
「質問がどんどん増えていった結果、見つけたい質問が見つけづらくなってしまった…」
サイト運営でよくありそうな展開です。
そんな時は、検索機能があると便利ですね。
例えば、「過去7日間の質問」や「今月の質問」などの投稿日フィルタ。
または、質問のテキストを検索する機能が望ましいですね。
Responder や SQLAlchemy の扱いに慣れてきている読者の皆様には、さほど難しい話ではないはずです!
投稿日のフィルタ機能を実装
投稿日のフィルタは、GETメソッド(URLQuery parameter または Query Strings ともいう)を使ってみましょう。
URLでたまにみると思いますが、「http://example.com?param=10」がそれにあたります。
Responder での URLクエリパラメータ の扱い方を考える前に、ビューだけ先に作ってしまいたいと思います。
ビューの修正
それでは、templates/administrator.html を修正していきましょう!
最終的な修正後コードは、以下に示しますが、修正・加筆箇所が少しややこしいので先にポイントを列挙しておきます。
- Bootstrap4のグリッド機能(<div class="row"> や<div class="col-md-X"> )でレイアウト調整(ひとまず9:3)
- 上記のグリッドの「9」に今までのテーブルを、「3」に検索機能のリストを表示させる
- 検索リストはBootstrapの「list-groupクラス」でデザイン
コード
1{% extends "layout.html" %}
2{% block content %}
3<br>
4<div class="row">
5 <div class="col-md-10">
6 <h1>Polls Application Administrator</h1>
7 </div>
8 <div class="col-md-2">
9 <form action="/logout" method="post">
10 <input type="hidden" name="token" value="{{auth_user}}">
11 <button type="submit" class="btn btn-primary">Logout</button>
12 </form>
13 </div>
14</div>
15<hr>
16<p>Hi, {{auth_user}}.</p>
17<br>
18
19<div class="row">
20 <div class="col-md-9">
21 <br>
22 <h4>Question <a href="/add_Question" class="btn-warning btn-sm">Add</a></h4>
23 <table class="table">
24 <thead class="thead-dark">
25 <tr>
26 <th>id</th>
27 <th scope="col">question_text</th>
28 <th scope="col">pub_date</th>
29 <th scope="col">Published recently?</th> <!-- New -->
30 <th scope="col"></th>
31 </tr>
32 </thead>
33 <tbody>
34 {% for question in questions%}
35 <tr>
36 <th scope="row">{{question['id']}}</th>
37 <td>{{question['question_text']}}</td>
38 <td>{{question['pub_date']}}</td>
39 <td> <!-- New -->
40 {% if not was_recently[loop.index-1] %}
41 <div class="text-danger"><strong>×</strong></div>
42 {% else %}
43 <div class="text-success"><strong>○</strong></div>
44 {% endif %}
45
46 </td>
47 <td>
48 <a href="/change/question/{{question['id']}}" class="btn-secondary btn-sm">Change</a>
49 <a href="/delete/question/{{question['id']}}" class="btn-danger btn-sm">Delete</a>
50 </td>
51 </tr>
52 {% endfor %}
53 </tbody>
54 </table>
55
56 <br>
57 <h4>Choice <a href="/add_Choice" class="btn-warning btn-sm">Add</a></h4>
58 <table class="table">
59 <thead class="thead-dark">
60 <tr>
61 <th>id</th>
62 <th scope="col">question</th>
63 <th scope="col">choice_text</th>
64 <th scope="col">votes</th>
65 <th scope="col"></th>
66 </tr>
67 </thead>
68 <tbody>
69 {% for choice in choices%}
70 <tr>
71 <th scope="row">{{choice['id']}}</th>
72 <td>{{choice['question']}}</td>
73 <td>{{choice['choice_text']}}</td>
74 <td>{{choice['votes']}}</td>
75 <td>
76 <a href="/change/choice/{{choice['id']}}" class="btn-secondary btn-sm">Change</a>
77 <a href="/delete/choice/{{choice['id']}}" class="btn-danger btn-sm">Delete</a>
78 </td>
79 </tr>
80 {% endfor %}
81 </tbody>
82 </table>
83 </div>
84 <div class="col-md-3">
85 <br><br><br>
86 <div class="list-group">
87 <div class="list-group-item list-group-item-info">質問フィルタ</div>
88 <a href="/admin_top" class="list-group-item list-group-item-action">すべて</a>
89 <a href="?filter=0" class="list-group-item list-group-item-action">今日</a>
90 <a href="?filter=7" class="list-group-item list-group-item-action">過去7日間</a>
91 <a href="?filter=31" class="list-group-item list-group-item-action">過去1ヶ月</a>
92 <a href="?filter=365" class="list-group-item list-group-item-action">過去1年</a>
93 </div>
94 </div>
95</div>
96
97
98{% endblock %}
確認
完成したビューは、以下のようになるはずです。
(ビューはなんでも良いのでコピペでOKです)
コントローラ @api.route('/admin_top')の修正
では、検索機能を実装していきます。
まず、URLクエリは、Responder において、req.param という辞書型変数に格納されます。
したがって取得するには、例えば「filter」という変数ならば、req.param['filter'] やreq.param.get('filter') のような形で取り出せます。
1@api.route('/admin_top')
2async def on_session(req, resp):
3 """
4 管理者ページ
5 """
6
7 authorized(req, resp, api)
8
9 # New! GETメソッドでフィルタを受け取る
10 date_filter = None
11 if 'filter' in req.params:
12 date_filter = req.params['filter']
13
14 # ログインユーザ名を取得
15 auth_user = req.cookies.get('username')
16
17 # データベースから質問一覧と選択肢を全て取得
18 # New! URLクエリがなければ全部取得
19 if date_filter is None:
20 questions = db.session.query(Question).all()
21 # New! URLクエリがあればフィルタをかけて取得
22 else:
23 today = datetime.now()
24 # 時間はいらない
25 date_range = datetime(today.year, today.month, today.day) - timedelta(days=int(date_filter))
26 questions = db.session.query(Question).filter(Question.pub_date >= date_range).all()
27
28 choices = db.session.query(Choice).all()
29 db.session.close()
30
31 # 直近の投稿か否か
32 was_recently = [q.was_published_recently() for q in questions]
33
34 # 各データを管理者ページに渡す
35 resp.content = api.template('administrator.html',
36 auth_user=auth_user,
37 questions=questions,
38 choices=choices,
39 was_recently=was_recently,
40 )
このようなコントローラで良いでしょう!
(追記部分には「New!」と書いてあります)
URLクエリで受け取ったものは、文字列(string)なので、整数として扱う場合はint() でキャストします。
質問テキスト検索を実装
それでは、次に「文字検索機能」を実装していきます。
まずは、ビューを適当に作りましょう。
ビューの作成
先ほどの「投稿日検索」のリストの上部にテキストボックスを作ります。
1<div class="col-md-3">
2 <br><br>
3 <! --- ここから --- >
4 <form action="" method="get">
5 <input type="text" size="15" name="q_str">
6 <button type="submit" class="btn btn-secondary btn-sm">Search</button>
7 </form>
8 <! --- ここまで追加 --- >
9 <br>
10 <div class="list-group">
11 <div class="list-group-item list-group-item-info">質問フィルタ</div>
12 <a href="/admin_top" class="list-group-item list-group-item-action">すべて</a>
13 <a href="?filter=0" class="list-group-item list-group-item-action">今日</a>
14 <a href="?filter=7" class="list-group-item list-group-item-action">過去7日間</a>
15 <a href="?filter=31" class="list-group-item list-group-item-action">過去1ヶ月</a>
16 <a href="?filter=365" class="list-group-item list-group-item-action">過去1年</a>
17 </div>
18 </div>
コントローラ修正
文字列検索もGETで受け取るようにしたので、先ほどと同じようにコントローラを修正しましょう。
また、GETで受け取る以上、URL操作で2つ(投稿日検索と文字列検索)の検索を同時にかけることも可能です。
(http://127.0.0.1:5042/admin_top?q_str=how&filter=300 のようなURLクエリ)
したがって、全通りのクエリ操作を実装していきます。
実装は面倒ですが、やっていることはさほど難しくありません。
文字列検索は、LIKE演算子を使いますが、SQLAlchemyではlike() 関数が用意されています。
実装
実装は、以下のようにできます。
1async def on_session(req, resp):
2 """
3 管理者ページ
4 """
5
6 authorized(req, resp, api)
7
8 # GETメソッドでフィルタを受け取る
9 date_filter = None
10 q_str = ''
11 if 'filter' in req.params:
12 date_filter = req.params['filter']
13
14 ''' [追加] 検索文字列を取得 '''
15 if 'q_str' in req.params:
16 q_str = req.params['q_str']
17
18 # ログインユーザ名を取得
19 auth_user = req.cookies.get('username')
20
21 # データベースから質問一覧と選択肢を全て取得
22 ''' [修正] URLクエリがなければ全部取得 '''
23 if date_filter is None and (q_str is None or q_str == ''):
24 questions = db.session.query(Question).all()
25 # URLクエリがあればフィルタをかけて取得
26 else:
27 ''' [修正] 各URLクエリの組み合わせによって処理を変える '''
28 if date_filter is not None:
29 today = datetime.now()
30 # 時間はいらない
31 date_range = datetime(today.year, today.month, today.day) - timedelta(days=int(date_filter))
32
33 if q_str is None and q_str == '': # 投稿日検索のみ
34 questions = db.session.query(Question).filter(Question.pub_date >= date_range).all()
35
36 else: # 両方
37 questions = db.session.query(Question).\
38 filter(Question.pub_date >= date_range, Question.question_text.like('%'+q_str+'%')).all()
39
40 else: # 文字列検索のみ
41 # LIKEで取得
42 questions = db.session.query(Question).filter(Question.question_text.like('%'+q_str+'%')).all()
43
44 choices = db.session.query(Choice).all()
45 db.session.close()
46
47 # 直近の投稿か否か
48 was_recently = [q.was_published_recently() for q in questions]
49
50 # 各データを管理者ページに渡す
51 resp.content = api.template('administrator.html',
52 auth_user=auth_user,
53 questions=questions,
54 choices=choices,
55 was_recently=was_recently,
56 )
確認
LIKE演算子によるものなので、日本語の検索や複雑な文字列検索には上手く対応できないかもしれません。
Djangoのチュートリアルにもある通り、常識的な範囲で使いましょう。
番外編へつづく!
お疲れ様でした!
これでResponderで追うDjangoチュートリアルが一通り終わりました!
この全7回を通して、「Responderの使い方」「SQLAlchemyの使い方」「Jinja2テンプレートエンジンの使い方」「Webアプリの基礎」など色々な知識が身についたと思います。
この連載で作成したアプリケーションは、まだまだ改良の余地があります。
しかし、ここまで一緒に実装してきた皆さんなら、より良いアプリケーション作りができるはずです。
Responderには、今回の連載では紹介しきれなかった、Responderの強み(非同期処理など)がまだまだあります。
また別の機会で、ご紹介できたらと思っています!
いちおう、本記事の第7回で終了ですが、「もう少しアプリケーションに工夫を加えるとしたら?」というようなコンセプトで、番外編に続きます。
お楽しみに!
番外編の記事はこちら
【全編まとめ】Responderを使ってDjangoチュートリアルをやってみた
こちらの記事もオススメ!
2020.07.17ライトコード的「やってみた!」シリーズ「やってみた!」を集めました!(株)ライトコードが今まで作ってきた「やってみた!」記事を集めてみました!※作成日が新し...
2020.07.30Python 特集実装編※最新記事順Responder + Firestore でモダンかつサーバーレスなブログシステムを作ってみた!P...
ライトコードでは、エンジニアを積極採用中!
ライトコードでは、エンジニアを積極採用しています!社長と一杯しながらお話しする機会もご用意しております。そのほかカジュアル面談等もございますので、くわしくは採用情報をご確認ください。
採用情報へ
「好きを仕事にするエンジニア集団」の(株)ライトコードです! ライトコードは、福岡、東京、大阪、名古屋の4拠点で事業展開するIT企業です。 現在は、国内を代表する大手IT企業を取引先にもち、ITシステムの受託事業が中心。 いずれも直取引で、月間PV数1億を超えるWebサービスのシステム開発・運営、インフラの構築・運用に携わっています。 システム開発依頼・お見積もり大歓迎! また、現在「WEBエンジニア」「モバイルエンジニア」「営業」「WEBデザイナー」を積極採用中です! インターンや新卒採用も行っております。 以下よりご応募をお待ちしております! https://rightcode.co.jp/recruit