【第8回】Responder + Firestore でモダンかつサーバーレスなブログシステムを作ってみた【管理者ページの仕上げ】
IT技術
第8回~モダンなフレームワークの使い方を学びながらブログシステムを構築~
連載「Python Responder + Firestore でモダンかつサーバーレスなブログシステムを作ってみる」第8回目です。
前回は、「メディアのアップロード機能」を実装しました。
管理者ページの整備を完成させよう
今回行うのは、「管理者ページの整備の仕上げ」です。
具体的には、以下の4点を実装していきます。
- プロフィール変更
- カテゴリの追加と修正
- タグの追加と修正
- 記事の編集・破棄
少し長くなりますが、ひとつひとつ確実にやっていきましょう!
プロフィール変更機能
それでは、ログインユーザの「プロフィール変更機能」から実装していきます。
ルーティングとビュー
まずは、いつも通り「ルーティング」と「ビュー」を作りましょう。
ルーティング
1# controllers.py
2@api.route('/admin/profile')
3def profile(req, resp):
4 if req.cookies.get('session') is None:
5 api.redirect(resp, '/login')
6
7 # ログイン済み
8 else:
9 # ユーザ情報取得
10 user_data = _get_user(req.cookies.get('email'))
11 resp.html = api.template('profile.html',
12 name=req.cookies.get('username'),
13 user_data=user_data # 関係のあるものだけビューに渡す
14 )
ユーザ情報を受け取る関数を用意
ここで、「ユーザ情報を受け取る関数」も用意しておきましょう。
1# auth.py
2def _get_user(id_token):
3 return auth.get_user_by_email(email).__dict__['_data']
firebase のモジュールに関数があるので、これだけでOKです。
また、auth.get_user_by_email(email) によって返るのはUserRecode 型なので、「辞書型変数」で必要なものだけ返すようにします。
ビュー
そして、ビューはひとまずこんな感じです。
1<!--
2templates/profile.html
3-->
4
5{% extends "layout.html" %}
6{% block content %}
7
8<br>
9<h1>My Blog Name | 管理者ページ</h1>
10<p>こんにちは,{{ name }} さん</p>
11
12<div class="main-container">
13 <div class="admin-main-menu">
14 <h2>プロフィール編集</h2>
15 <div class="edit-info">
16 <form action="/admin/profile/update" method="post">
17 <p>表示名 : <input type="text" name="displayName" value="{{ user_data['displayName'] }}"></p>
18 <p>メールアドレス : <input type="text" name="email" value="{{ user_data['email'] }}"></p>
19 <p>新しいパスワード : <input type="password" name="Password" value=""></p>
20 <p>新しいパスワード(確認用) : <input type="password" name="tmp_password" value=""></p>
21 <button type="submit" value="update">更新</button>
22 </form>
23 </div>
24 </div>
25
26 {% include 'admin-side.html'%}
27
28</div>
29{% endblock %}
CSS
デザインは好きなもので構いません。
上記サンプルの CSS は、以下となります。
1# 追加CSS
2.edit-info input{
3 width: 20%;
4 padding: 10px 15px;
5 font-size: 16px;
6 border-radius: 3px;
7 border: 2px solid #ddd;
8 box-sizing: border-box;
9}
情報を更新する関数を作っておく
「/admin/profile/update」を作る前に、「Firebase の管理者情報を更新する関数」を作成しておきます。
RestAPI を使用しますが、この関数も Python の firebase モジュールにあります。
1# auth.py
2def _change_user_info(id_token, new_email=None, new_password=None, new_displayName=None):
3 return auth.update_user(id_token,
4 email=new_email,
5 password=new_password,
6 display_name=new_displayName).__dict__['_data']
内容は、前に書いた「ユーザ取得関数」と大きな違いはありません。
これも、「辞書型変数」に直して、必要なデータだけ受け取るようにします。
管理者情報のアップデート処理のルーティング
では、実際に処理を書いていきましょう。
実装する処理内容は、以下の通りです。
- POSTデータを取得
- POSTデータに誤りがないかチェック
- 更新
- エラーがあればエラー処理
- なければ更新してクッキーも更新
さっそく実装してみます。
1# conttrolles.py
2@api.route('/admin/profile/update')
3async def profile_update(req, resp):
4 if req.cookies.get('session') is None or req.media == 'get': # GETは受け付けない
5 api.redirect(resp, '/login')
6
7 # ログイン済み
8 else:
9 # ユーザ情報取得
10 data = await req.media()
11 email = data.get('email', None)
12 username = data.get('displayName', None)
13 password = data.get('password', None)
14 tmp_password = data.get('tmp_password', None)
15
16 if password != tmp_password and password is not None:
17 # 本当はエラー処理を入れた方が良いが割愛
18 api.redirect(resp, '/admin/profile')
19 return
20
21 # アップデート
22 user = _get_user(req.cookies.get('email'))
23 res = _change_user_info(user['localId'],
24 new_email=email,
25 new_password=password,
26 new_displayName=username)
27
28 # エラー処理を入れるのが望ましいがここでは割愛
29 # ここで、クッキーにも反映させておく
30 expires = datetime.now() + timedelta(COOKIE_EXPIRES)
31 resp.set_cookie(key='username', value=username, expires=expires)
32
33 api.redirect(resp, '/admin/profile')
今回は細かいエラー処理は割愛しますが、本番サーバに上げる場合はしっかり実装しましょう!
カテゴリ (タグ) の追加と編集
次に、「カテゴリとタグに関するページ」を作っていきます。
ルーティングとビューの作成
ルーティング
1# controllers.py
2@api.route('/admin/category')
3def category(req, resp):
4 if req.cookies.get('session') is None:
5 api.redirect(resp, '/login')
6
7 # ログイン済み
8 else:
9 # 今あるカテゴリ取得
10 categories = get_categories()
11 resp.html = api.template('category.html',
12 name=req.cookies.get('username'),
13 categories=categories)
ビュー
カテゴリのビューでは、「編集」「削除」「新規追加」を行えるようにします。
1<!--
2templates/category.html
3-->
4
5{% extends "layout.html" %}
6{% block content %}
7
8<br>
9<h1>My Blog Name | 管理者ページ</h1>
10<p>こんにちは,{{ name }} さん</p>
11
12<div class="main-container">
13 <div class="admin-main-menu">
14 <h2>カテゴリ一覧</h2>
15 <div class="edit-info">
16 <form action="/admin/category/update" method="post">
17 {% for cat in categories %}
18 名前 <input type="text" name="{{cat['slug']}}" value="{{cat['name']}}"> :
19 スラッグ <input type="text" name="{{cat['slug']}}" value="{{cat['slug']}}">
20 <a href="/admin/category/delete?={{cat['slug']}}" class="delete-btn">×</a>
21 <br>
22 {% endfor %}
23 <button type="submit" value="update">カテゴリの更新</button>
24 </form>
25 </div>
26
27 <h2>カテゴリ新規追加</h2>
28 <div class="edit-info">
29 <form action="/admin/category/add" method="post">
30 名前 <input type="text" name="new_cat_name" value=""> :
31 スラッグ <input type="text" name="new_cat_slug" value=""> <br>
32 <button type="submit" value="update">カテゴリの追加</button>
33 </form>
34 </div>
35 </div>
36
37 {% include 'admin-side.html'%}
38
39</div>
40{% endblock %}
1.delete-btn{
2 background-color: #b6100f;
3 color: #fff;
4 padding: 5px 10px;
5 border-radius: 5px;
6}
7.delete-btn:hover{
8 opacity: 0.7;
9}
こんな感じにしました。
それでは、処理を書いていきましょう!
新規追加
「新規追加」は、受け取った POST データをもとにデータを作って、アップロードするだけです。
以下のように実装すればOKです。
1# controllers.py
2@api.route('/admin/category/add')
3async def add_category(req, resp):
4 if req.cookies.get('session') is None or req.media == 'get': # GETは受け付けない
5 api.redirect(resp, '/login')
6
7 # ログイン済み
8 else:
9 # ユーザ情報取得
10 data = await req.media()
11 name = data.get('new_cat_name', None)
12 slug = data.get('new_cat_slug', None)
13
14 if name is None or slug is None: # error: データがありません
15 # 本当はエラー処理を追加すべきだが割愛
16 api.redirect(resp, '/admin/category')
17 return
18
19 if slug in get_category_slugs(): # error: 既に同じスラッグが存在します
20 # 本当はエラー処理を追加すべきだが割愛 その2
21 api.redirect(resp, '/admin/category')
22 return
23
24 # カテゴリを追加
25 cat = Category(name, slug)
26 cat.add()
27
28 api.redirect(resp, '/admin/category')
カテゴリの削除
ドキュメント ID が分かれば、delete() メソッドで削除できます。
今回、スラッグは一意に定まるものとしているので、以下のような実装で良いでしょう。
関数を作成
一旦、モデル側で関数を用意します。
1# models.py
2def delete_category_by_slug(slug: str):
3 # slugからIDを取得
4 c_id = [cat.id for cat in db.collection('categories').where('slug', '==', slug).stream()][0]
5
6 # ドキュメントを削除
7 db.collection('categories').document(c_id).delete()
コントローラ処理
コントローラ側の処理は、以下のようになります。
1# controllers.py
2@api.route('/admin/category/delete')
3def delete_category(req, resp):
4 if req.cookies.get('session') is None:
5 api.redirect(resp, '/login')
6
7 # ログイン済み
8 else:
9 # GETデータ取得
10 cat_slug = req.params.get('slug', None)
11
12 if cat_slug is None:
13 api.redirect(resp, '/admin/category')
14 return
15
16 # delete
17 delete_category_by_slug(cat_slug)
18 api.redirect(resp, '/admin/category')
カテゴリの更新
まずは、「スラッグでカテゴリを取得し、新しいデータに変える関数」を作成します。
1# models.py
2def update_category_by_slug(slug: str, new_name: str = None, new_slug: str = None):
3 # slugからIDを取得
4 c_id = [cat.id for cat in db.collection('categories').where('slug', '==', slug).stream()][0]
5
6 data = {}
7 if new_name is not None:
8 data['name'] = new_name
9 if new_slug is not None:
10 data['slug'] = new_slug
11
12 db.collection('categories').document(c_id).update(data)
コントローラ処理
あとは、POST で受け取ったデータをコントローラ側で展開し、この関数を使って更新していくだけです。
以下のような実装で良いでしょう。
1# controllers.py
2@api.route('/admin/category/update')
3async def update_category(req, resp):
4 if req.cookies.get('session') is None or req.media == 'get': # GETは受け付けない
5 api.redirect(resp, '/login')
6
7 # ログイン済み
8 else:
9 # ユーザ情報取得
10 data = await req.media()
11 categories = get_category_slugs()
12
13 # 全ての更新情報を取得
14 names = [
15 data.get(f'cat_name_{cat_slug}', None) for cat_slug in categories
16 ]
17 slugs = [
18 data.get(f'cat_slug_{cat_slug}', None) for cat_slug in categories
19 ]
20
21 for slug, new_name, new_slug in zip(categories, names, slugs):
22 update_category_by_slug(slug, new_name, new_slug)
23
24 api.redirect(resp, '/admin/category')
タグの実装手順も同じ
カテゴリとタグの実装手順は同じです。
タグの「更新」「削除」「新規追加」も、上記の手順に倣って実装してください。
記事の編集・破棄
記事の編集画面は、以前作成した新規追加ページ「new.html」を使います。
やることは以下の2つだけなので、何も難しいことはありません。
- 「/admin/new」なら、input value 要素には何も入れない
- 「/admin/edit/{slug} 」なら、記事の要素を input value要素に入れる + α
ルーティング
以前実装したget_article(slug) で、記事をコントローラ側で取得し、ビューに渡すだけです。
このとき、ビューでの POST 先は、以下のように設定します。
- 新規追加なら「/admin/add」
- 記事更新なら「/admin/article/update」
1# controllers.py
2@api.route('/admin/edit/{slug}')
3def edit(req, resp, *, slug):
4 if req.cookies.get('session') is None:
5 api.redirect(resp, '/login')
6
7 # ログイン済み
8 else:
9 article = get_article(slug)
10 categories = get_categories()
11 tags = get_tags()
12 resp.html = api.template('new.html',
13 action='article/update', # POST先URL
14 slug=slug,
15 article=article, # 現在の記事情報
16 name=req.cookies.get('username'),
17 categories=categories,
18 tags=tags
19 )
new.htmlの修正
次に、記事を展開するために、ビューを修正します。
修正点は以下の3点です。
- 空だった value の中身を変更
- POST 先 URL を追加
- カテゴリのチェックボックスを checked にする
1<!--
2templates/new.html
3記事編集ページ
4-->
5
6{% extends "layout.html" %}
7{% block content %}
8
9<br>
10<h1>My Blog Name | 管理者ページ</h1>
11<p>こんにちは,{{ name }} さん</p>
12
13<div class="main-container">
14 <div class="admin-main-menu">
15 <h2>投稿の編集</h2> <!-- ここも適当に変更してみた -->
16 <div class="edit">
17 <form action="/admin/{{action}}" method="post">
18 <input type="hidden" value="{{slug}}" name="original_slug">
19 <h3>サムネイル</h3>
20 <input type="text" name="thumbnail" value="{{ article['thumbnail'] }}" placeholder="![画像の代替テキスト](画像パス)">
21 <h3>タイトル</h3>
22 <input type="text" name="title" value="{{ article['title'] }}">
23 <h3>スラッグ</h3>
24 <input type="text" name="slug" value="{{ article['slug'] }}">
25 <h3>内容</h3>
26 <textarea name="contents" id="contents">{{ article['contents'] }}</textarea>
27 <h3>カテゴリ</h3>
28 {% for cat in categories %}
29 <input type="radio" name="category" value="{{cat['slug']}}"{% if cat['slug'] == article['category'] %} checked {% endif %}> {{cat['name']}}
30 {% endfor %}
31 <h3>タグ</h3>
32 {% for tag in tags %}
33 <input type="checkbox" name="tags[]" value="{{tag['slug']}}" {% if tag['slug'] in article['tags'] %} checked {% endif %}> {{tag['name']}}
34 {% endfor %}
35 <h3>詳細・抜粋</h3>
36 <textarea name="description" id="description">{{ article['description'] }}</textarea>
37 <p>
38 <button type="submit" name="draft" value="draft" id="draft">下書き保存</button>
39 <button type="submit" name="preview" value="preview" id="preview" formtarget="_blank">プレビュー </button>
40 <button type="submit" name="release" value="release" id="release">公開</button>
41 </p>
42 </form>
43 </div>
44 </div>
45
46 {% include 'admin-side.html'%}
47</div>
48
49{% endblock %}
このとき、<input type="hidden" value="{{slug}}" name="original_slug"> で、オリジナルのスラッグをとっておきましょう。
スラッグが変更されても、記事をしっかり更新するためです。
/admin/new のコントローラも編集しておく
新規追加する際にも、引数としてarticle をビューに渡さないとエラーになるので、None を渡しておきます。
1# controllers.py
2@api.route('/admin/new')
3def new_article(req, resp):
4 if req.cookies.get('session') is None:
5 api.redirect(resp, '/login')
6
7 # ログイン済み
8 else:
9 categories = get_categories()
10 tags = get_tags()
11 resp.html = api.template('new.html',
12 action='add', # new
13 slug=None, # new
14 name=req.cookies.get('username'),
15 article=None, # new
16 categories=categories,
17 tags=tags)
これでOKです。
記事のアップデート処理
あとは、記事の「更新」をするコードを書いていきます。
これは、先ほどのカテゴリ更新とほとんど同じです。
関数を作成
まずは、「スラッグから ID を取得して、記事のフィールドを更新する関数」を作ります。
1# models.py
2def update_article_by_slug(original_slug,
3 title,
4 thumbnail,
5 description,
6 slug,
7 contents,
8 category,
9 tags,
10 released,
11 ):
12 # IDを取得
13 art_id = [art.id for art in db.collection('articles').where('slug', '==', original_slug).stream()][0]
14
15 # 更新
16 db.collection('articles').document(art_id).update({
17 'title': title,
18 'thumbnail': thumbnail,
19 'description': description,
20 'slug': slug,
21 'contents': contents,
22 'category': category,
23 'tags': tags,
24 'last_update': datetime.now(),
25 'released': released,
26 })
コントローラ処理
そして、コントローラ側を実装していきます。
1# controllers.py
2@api.route('/admin/article/update')
3async def article_update(req, resp):
4 if req.cookies.get('session') is None or req.media == 'get':
5 api.redirect(resp, '/admin')
6 return
7
8 data = await req.media()
9 original_slug = data.get('original_slug', None)
10 title = data.get('title', '')
11 thumbnail = data.get('thumbnail', '') # 変更
12 description = data.get('description', '')
13 slug = data.get('slug', '').lower().replace(' ', '-') # 全て小文字かつ空白は - に置換
14 contents = data.get('contents', '')
15 category = data.get('category', '')
16 tags = data.get_list('tags')
17
18 # プレビュー の場合
19 if data.get('preview', None) is not None:
20 # マークダウンからHTMLへ
21 # tableはでデフォルトでは変換してくれないのでここで指定する
22 md = markdown.Markdown(extensions=['tables'])
23 html = md.convert(contents)
24
25 thumbnail = md.convert(thumbnail) # 追加 md => html
26
27 whats_new = get_whats_new()
28 categories = get_categories()
29 resp.html = api.template('preview.html',
30 title=title,
31 contents=html,
32 thumbnail=thumbnail,
33 category=category,
34 tags=tags,
35 whats_new=whats_new,
36 categories=categories
37 )
38 return
39
40 released = False if data.get('draft', None) is not None else True
41 # update
42 update_article_by_slug(
43 original_slug,
44 title,
45 thumbnail,
46 description,
47 slug,
48 contents,
49 category,
50 tags,
51 released
52 )
53
54 api.redirect(resp, '/admin')
55 return
長いですが、中身は「記事追加」「カテゴリ更新」のコントローラとさほど変わらないので、大丈夫でしょう!
確認してみると
上出来です!
更新機能も、上手く動いていそうですね!
記事の削除
最後に、記事の「削除」について実装します。
削除ボタンを追加
まずは、先ほどのビューの「記事編集画面」に削除ボタンを追加しましょう。
1# new.html 一部抜粋
2<div class="main-container">
3 <div class="admin-main-menu">
4 <h2>投稿の編集</h2>
5 {% if article %}
6 <p><a href="/admin/article/delete?slug={{article['slug']}}" class="delete-btn">この記事を削除</a></p>
7 {% endif%}
8 <div class="edit">
場所はどこでも良いですが、とりあえず画面上部に設置しました。
関数を作成
記事削除の関数を作ります。
1# models.py
2def delete_article_by_slug(slug: str):
3 # slugからIDを取得
4 art_id = [art.id for art in db.collection('articles').where('slug', '==', slug).stream()][0]
5
6 # ドキュメントを削除
7 db.collection('articles').document(art_id).delete()
コントローラ処理
続いて、コントローラをコーディングします。
1# controllers.py
2@api.route('/admin/article/delete')
3def delete_article(req, resp):
4 if req.cookies.get('session') is None:
5 api.redirect(resp, '/login')
6
7 # ログイン済み
8 else:
9 # GETデータ取得
10 art_slug = req.params.get('slug', None)
11
12 if art_slug is None:
13 api.redirect(resp, '/admin')
14 return
15
16 # delete
17 delete_article_by_slug(art_slug)
18
19 api.redirect(resp, '/admin')
これで、管理者ページの実装は全て完了です!
お疲れ様でした!
第9回へつづく!
今回は長い道のりでしたが、一気に管理者ページを仕上げることができました。
あとは、ブログを表示する「フロント側の実装」がメインになります。
もう終盤戦です、このまま頑張っていきましょう!
次回の記事はこちら
第1回はこちら
こちらの記事もオススメ!
2020.07.17ライトコード的「やってみた!」シリーズ「やってみた!」を集めました!(株)ライトコードが今まで作ってきた「やってみた!」記事を集めてみました!※作成日が新し...
2020.07.30Python 特集実装編※最新記事順Responder + Firestore でモダンかつサーバーレスなブログシステムを作ってみた!P...
ライトコードでは、エンジニアを積極採用中!
ライトコードでは、エンジニアを積極採用しています!社長と一杯しながらお話しする機会もご用意しております。そのほかカジュアル面談等もございますので、くわしくは採用情報をご確認ください。
採用情報へ
「好きを仕事にするエンジニア集団」の(株)ライトコードです! ライトコードは、福岡、東京、大阪、名古屋の4拠点で事業展開するIT企業です。 現在は、国内を代表する大手IT企業を取引先にもち、ITシステムの受託事業が中心。 いずれも直取引で、月間PV数1億を超えるWebサービスのシステム開発・運営、インフラの構築・運用に携わっています。 システム開発依頼・お見積もり大歓迎! また、現在「WEBエンジニア」「モバイルエンジニア」「営業」「WEBデザイナー」を積極採用中です! インターンや新卒採用も行っております。 以下よりご応募をお待ちしております! https://rightcode.co.jp/recruit