
【第8回】Responder + Firestore でモダンかつサーバーレスなブログシステムを作ってみた【管理者ページの仕上げ】
2021.12.20
第8回~モダンなフレームワークの使い方を学びながらブログシステムを構築~

連載「Python Responder + Firestore でモダンかつサーバーレスなブログシステムを作ってみる」第8回目です。
前回は、「メディアのアップロード機能」を実装しました。
管理者ページの整備を完成させよう
今回行うのは、「管理者ページの整備の仕上げ」です。
具体的には、以下の4点を実装していきます。
- プロフィール変更
- カテゴリの追加と修正
- タグの追加と修正
- 記事の編集・破棄
少し長くなりますが、ひとつひとつ確実にやっていきましょう!
プロフィール変更機能
それでは、ログインユーザの「プロフィール変更機能」から実装していきます。
ルーティングとビュー
まずは、いつも通り「ルーティング」と「ビュー」を作りましょう。
ルーティング
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | # controllers.py @api.route('/admin/profile') def profile(req, resp): if req.cookies.get('session') is None: api.redirect(resp, '/login') # ログイン済み else: # ユーザ情報取得 user_data = _get_user(req.cookies.get('email')) resp.html = api.template('profile.html', name=req.cookies.get('username'), user_data=user_data # 関係のあるものだけビューに渡す ) |
ユーザ情報を受け取る関数を用意
ここで、「ユーザ情報を受け取る関数」も用意しておきましょう。
1 2 3 | # auth.py def _get_user(id_token): return auth.get_user_by_email(email).__dict__['_data'] |
firebase のモジュールに関数があるので、これだけでOKです。
また、 auth.get_user_by_email(email) によって返るのは UserRecode 型なので、「辞書型変数」で必要なものだけ返すようにします。
ビュー
そして、ビューはひとまずこんな感じです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 | <!-- templates/profile.html --> {% extends "layout.html" %} {% block content %} <br> <h1>My Blog Name | 管理者ページ</h1> <p>こんにちは,{{ name }} さん</p> <div class="main-container"> <div class="admin-main-menu"> <h2>プロフィール編集</h2> <div class="edit-info"> <form action="/admin/profile/update" method="post"> <p>表示名 : <input type="text" name="displayName" value="{{ user_data['displayName'] }}"></p> <p>メールアドレス : <input type="text" name="email" value="{{ user_data['email'] }}"></p> <p>新しいパスワード : <input type="password" name="Password" value=""></p> <p>新しいパスワード(確認用) : <input type="password" name="tmp_password" value=""></p> <button type="submit" value="update">更新</button> </form> </div> </div> {% include 'admin-side.html'%} </div> {% endblock %} |

ユーザプロフィール変更画面
CSS
デザインは好きなもので構いません。
上記サンプルの CSS は、以下となります。
1 2 3 4 5 6 7 8 9 | # 追加CSS .edit-info input{ width: 20%; padding: 10px 15px; font-size: 16px; border-radius: 3px; border: 2px solid #ddd; box-sizing: border-box; } |
情報を更新する関数を作っておく
「/admin/profile/update」を作る前に、「Firebase の管理者情報を更新する関数」を作成しておきます。
RestAPI を使用しますが、この関数も Python の firebase モジュールにあります。
1 2 3 4 5 6 | # auth.py def _change_user_info(id_token, new_email=None, new_password=None, new_displayName=None): return auth.update_user(id_token, email=new_email, password=new_password, display_name=new_displayName).__dict__['_data'] |
内容は、前に書いた「ユーザ取得関数」と大きな違いはありません。
これも、「辞書型変数」に直して、必要なデータだけ受け取るようにします。
管理者情報のアップデート処理のルーティング
では、実際に処理を書いていきましょう。
実装する処理内容は、以下の通りです。
- POSTデータを取得
- POSTデータに誤りがないかチェック
- 更新
- エラーがあればエラー処理
- なければ更新してクッキーも更新
さっそく実装してみます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 | # conttrolles.py @api.route('/admin/profile/update') async def profile_update(req, resp): if req.cookies.get('session') is None or req.media == 'get': # GETは受け付けない api.redirect(resp, '/login') # ログイン済み else: # ユーザ情報取得 data = await req.media() email = data.get('email', None) username = data.get('displayName', None) password = data.get('password', None) tmp_password = data.get('tmp_password', None) if password != tmp_password and password is not None: # 本当はエラー処理を入れた方が良いが割愛 api.redirect(resp, '/admin/profile') return # アップデート user = _get_user(req.cookies.get('email')) res = _change_user_info(user['localId'], new_email=email, new_password=password, new_displayName=username) # エラー処理を入れるのが望ましいがここでは割愛 # ここで、クッキーにも反映させておく expires = datetime.now() + timedelta(COOKIE_EXPIRES) resp.set_cookie(key='username', value=username, expires=expires) api.redirect(resp, '/admin/profile') |
今回は細かいエラー処理は割愛しますが、本番サーバに上げる場合はしっかり実装しましょう!
カテゴリ (タグ) の追加と編集
次に、「カテゴリとタグに関するページ」を作っていきます。
ルーティングとビューの作成
ルーティング
1 2 3 4 5 6 7 8 9 10 11 12 13 | # controllers.py @api.route('/admin/category') def category(req, resp): if req.cookies.get('session') is None: api.redirect(resp, '/login') # ログイン済み else: # 今あるカテゴリ取得 categories = get_categories() resp.html = api.template('category.html', name=req.cookies.get('username'), categories=categories) |
ビュー
カテゴリのビューでは、「編集」「削除」「新規追加」を行えるようにします。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 | <!-- templates/category.html --> {% extends "layout.html" %} {% block content %} <br> <h1>My Blog Name | 管理者ページ</h1> <p>こんにちは,{{ name }} さん</p> <div class="main-container"> <div class="admin-main-menu"> <h2>カテゴリ一覧</h2> <div class="edit-info"> <form action="/admin/category/update" method="post"> {% for cat in categories %} 名前 <input type="text" name="{{cat['slug']}}" value="{{cat['name']}}"> : スラッグ <input type="text" name="{{cat['slug']}}" value="{{cat['slug']}}"> <a href="/admin/category/delete?={{cat['slug']}}" class="delete-btn">×</a> <br> {% endfor %} <button type="submit" value="update">カテゴリの更新</button> </form> </div> <h2>カテゴリ新規追加</h2> <div class="edit-info"> <form action="/admin/category/add" method="post"> 名前 <input type="text" name="new_cat_name" value=""> : スラッグ <input type="text" name="new_cat_slug" value=""> <br> <button type="submit" value="update">カテゴリの追加</button> </form> </div> </div> {% include 'admin-side.html'%} </div> {% endblock %} |

カテゴリ編集画面
1 2 3 4 5 6 7 8 9 | .delete-btn{ background-color: #b6100f; color: #fff; padding: 5px 10px; border-radius: 5px; } .delete-btn:hover{ opacity: 0.7; } |
こんな感じにしました。
それでは、処理を書いていきましょう!
新規追加
「新規追加」は、受け取った POST データをもとにデータを作って、アップロードするだけです。
以下のように実装すればOKです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | # controllers.py @api.route('/admin/category/add') async def add_category(req, resp): if req.cookies.get('session') is None or req.media == 'get': # GETは受け付けない api.redirect(resp, '/login') # ログイン済み else: # ユーザ情報取得 data = await req.media() name = data.get('new_cat_name', None) slug = data.get('new_cat_slug', None) if name is None or slug is None: # error: データがありません # 本当はエラー処理を追加すべきだが割愛 api.redirect(resp, '/admin/category') return if slug in get_category_slugs(): # error: 既に同じスラッグが存在します # 本当はエラー処理を追加すべきだが割愛 その2 api.redirect(resp, '/admin/category') return # カテゴリを追加 cat = Category(name, slug) cat.add() api.redirect(resp, '/admin/category') |

新規追加できた
カテゴリの削除
ドキュメント ID が分かれば、 delete() メソッドで削除できます。
今回、スラッグは一意に定まるものとしているので、以下のような実装で良いでしょう。
関数を作成
一旦、モデル側で関数を用意します。
1 2 3 4 5 6 7 | # models.py def delete_category_by_slug(slug: str): # slugからIDを取得 c_id = [cat.id for cat in db.collection('categories').where('slug', '==', slug).stream()][0] # ドキュメントを削除 db.collection('categories').document(c_id).delete() |
コントローラ処理
コントローラ側の処理は、以下のようになります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | # controllers.py @api.route('/admin/category/delete') def delete_category(req, resp): if req.cookies.get('session') is None: api.redirect(resp, '/login') # ログイン済み else: # GETデータ取得 cat_slug = req.params.get('slug', None) if cat_slug is None: api.redirect(resp, '/admin/category') return # delete delete_category_by_slug(cat_slug) api.redirect(resp, '/admin/category') |
カテゴリの更新
まずは、「スラッグでカテゴリを取得し、新しいデータに変える関数」を作成します。
1 2 3 4 5 6 7 8 9 10 11 12 | # models.py def update_category_by_slug(slug: str, new_name: str = None, new_slug: str = None): # slugからIDを取得 c_id = [cat.id for cat in db.collection('categories').where('slug', '==', slug).stream()][0] data = {} if new_name is not None: data['name'] = new_name if new_slug is not None: data['slug'] = new_slug db.collection('categories').document(c_id).update(data) |
コントローラ処理
あとは、POST で受け取ったデータをコントローラ側で展開し、この関数を使って更新していくだけです。
以下のような実装で良いでしょう。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | # controllers.py @api.route('/admin/category/update') async def update_category(req, resp): if req.cookies.get('session') is None or req.media == 'get': # GETは受け付けない api.redirect(resp, '/login') # ログイン済み else: # ユーザ情報取得 data = await req.media() categories = get_category_slugs() # 全ての更新情報を取得 names = [ data.get(f'cat_name_{cat_slug}', None) for cat_slug in categories ] slugs = [ data.get(f'cat_slug_{cat_slug}', None) for cat_slug in categories ] for slug, new_name, new_slug in zip(categories, names, slugs): update_category_by_slug(slug, new_name, new_slug) 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 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | # controllers.py @api.route('/admin/edit/{slug}') def edit(req, resp, *, slug): if req.cookies.get('session') is None: api.redirect(resp, '/login') # ログイン済み else: article = get_article(slug) categories = get_categories() tags = get_tags() resp.html = api.template('new.html', action='article/update', # POST先URL slug=slug, article=article, # 現在の記事情報 name=req.cookies.get('username'), categories=categories, tags=tags ) |
new.htmlの修正
次に、記事を展開するために、ビューを修正します。
修正点は以下の3点です。
- 空だった value の中身を変更
- POST 先 URL を追加
- カテゴリのチェックボックスを checked にする
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 | <!-- templates/new.html 記事編集ページ --> {% extends "layout.html" %} {% block content %} <br> <h1>My Blog Name | 管理者ページ</h1> <p>こんにちは,{{ name }} さん</p> <div class="main-container"> <div class="admin-main-menu"> <h2>投稿の編集</h2> <!-- ここも適当に変更してみた --> <div class="edit"> <form action="/admin/{{action}}" method="post"> <input type="hidden" value="{{slug}}" name="original_slug"> <h3>サムネイル</h3> <input type="text" name="thumbnail" value="{{ article['thumbnail'] }}" placeholder=""> <h3>タイトル</h3> <input type="text" name="title" value="{{ article['title'] }}"> <h3>スラッグ</h3> <input type="text" name="slug" value="{{ article['slug'] }}"> <h3>内容</h3> <textarea name="contents" id="contents">{{ article['contents'] }}</textarea> <h3>カテゴリ</h3> {% for cat in categories %} <input type="radio" name="category" value="{{cat['slug']}}"{% if cat['slug'] == article['category'] %} checked {% endif %}> {{cat['name']}} {% endfor %} <h3>タグ</h3> {% for tag in tags %} <input type="checkbox" name="tags[]" value="{{tag['slug']}}" {% if tag['slug'] in article['tags'] %} checked {% endif %}> {{tag['name']}} {% endfor %} <h3>詳細・抜粋</h3> <textarea name="description" id="description">{{ article['description'] }}</textarea> <p> <button type="submit" name="draft" value="draft" id="draft">下書き保存</button> <button type="submit" name="preview" value="preview" id="preview" formtarget="_blank">プレビュー </button> <button type="submit" name="release" value="release" id="release">公開</button> </p> </form> </div> </div> {% include 'admin-side.html'%} </div> {% endblock %} |
このとき、 <input type="hidden" value="{{slug}}" name="original_slug"> で、オリジナルのスラッグをとっておきましょう。
スラッグが変更されても、記事をしっかり更新するためです。
/admin/new のコントローラも編集しておく
新規追加する際にも、引数として article をビューに渡さないとエラーになるので、 None を渡しておきます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | # controllers.py @api.route('/admin/new') def new_article(req, resp): if req.cookies.get('session') is None: api.redirect(resp, '/login') # ログイン済み else: categories = get_categories() tags = get_tags() resp.html = api.template('new.html', action='add', # new slug=None, # new name=req.cookies.get('username'), article=None, # new categories=categories, tags=tags) |
これでOKです。
記事のアップデート処理
あとは、記事の「更新」をするコードを書いていきます。
これは、先ほどのカテゴリ更新とほとんど同じです。
関数を作成
まずは、「スラッグから ID を取得して、記事のフィールドを更新する関数」を作ります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | # models.py def update_article_by_slug(original_slug, title, thumbnail, description, slug, contents, category, tags, released, ): # IDを取得 art_id = [art.id for art in db.collection('articles').where('slug', '==', original_slug).stream()][0] # 更新 db.collection('articles').document(art_id).update({ 'title': title, 'thumbnail': thumbnail, 'description': description, 'slug': slug, 'contents': contents, 'category': category, 'tags': tags, 'last_update': datetime.now(), 'released': released, }) |
コントローラ処理
そして、コントローラ側を実装していきます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 | # controllers.py @api.route('/admin/article/update') async def article_update(req, resp): if req.cookies.get('session') is None or req.media == 'get': api.redirect(resp, '/admin') return data = await req.media() original_slug = data.get('original_slug', None) title = data.get('title', '') thumbnail = data.get('thumbnail', '') # 変更 description = data.get('description', '') slug = data.get('slug', '').lower().replace(' ', '-') # 全て小文字かつ空白は - に置換 contents = data.get('contents', '') category = data.get('category', '') tags = data.get_list('tags') # プレビュー の場合 if data.get('preview', None) is not None: # マークダウンからHTMLへ # tableはでデフォルトでは変換してくれないのでここで指定する md = markdown.Markdown(extensions=['tables']) html = md.convert(contents) thumbnail = md.convert(thumbnail) # 追加 md => html whats_new = get_whats_new() categories = get_categories() resp.html = api.template('preview.html', title=title, contents=html, thumbnail=thumbnail, category=category, tags=tags, whats_new=whats_new, categories=categories ) return released = False if data.get('draft', None) is not None else True # update update_article_by_slug( original_slug, title, thumbnail, description, slug, contents, category, tags, released ) api.redirect(resp, '/admin') return |
長いですが、中身は「記事追加」「カテゴリ更新」のコントローラとさほど変わらないので、大丈夫でしょう!
確認してみると

しっかり記事内容が反映されている
上出来です!
更新機能も、上手く動いていそうですね!
記事の削除
最後に、記事の「削除」について実装します。
削除ボタンを追加
まずは、先ほどのビューの「記事編集画面」に削除ボタンを追加しましょう。
1 2 3 4 5 6 7 8 | # new.html 一部抜粋 <div class="main-container"> <div class="admin-main-menu"> <h2>投稿の編集</h2> {% if article %} <p><a href="/admin/article/delete?slug={{article['slug']}}" class="delete-btn">この記事を削除</a></p> {% endif%} <div class="edit"> |
場所はどこでも良いですが、とりあえず画面上部に設置しました。
関数を作成
記事削除の関数を作ります。
1 2 3 4 5 6 7 | # models.py def delete_article_by_slug(slug: str): # slugからIDを取得 art_id = [art.id for art in db.collection('articles').where('slug', '==', slug).stream()][0] # ドキュメントを削除 db.collection('articles').document(art_id).delete() |
コントローラ処理
続いて、コントローラをコーディングします。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | # controllers.py @api.route('/admin/article/delete') def delete_article(req, resp): if req.cookies.get('session') is None: api.redirect(resp, '/login') # ログイン済み else: # GETデータ取得 art_slug = req.params.get('slug', None) if art_slug is None: api.redirect(resp, '/admin') return # delete delete_article_by_slug(art_slug) api.redirect(resp, '/admin') |
これで、管理者ページの実装は全て完了です!
お疲れ様でした!
第9回へつづく!
今回は長い道のりでしたが、一気に管理者ページを仕上げることができました。
あとは、ブログを表示する「フロント側の実装」がメインになります。
もう終盤戦です、このまま頑張っていきましょう!
次回の記事はこちら
第1回はこちら
こちらの記事もオススメ!
書いた人はこんな人

- 「好きを仕事にするエンジニア集団」の(株)ライトコードです!
ライトコードは、福岡、東京、大阪の3拠点で事業展開するIT企業です。
現在は、国内を代表する大手IT企業を取引先にもち、ITシステムの受託事業が中心。
いずれも直取引で、月間PV数1億を超えるWebサービスのシステム開発・運営、インフラの構築・運用に携わっています。
システム開発依頼・お見積もり大歓迎!
また、現在「WEBエンジニア」「モバイルエンジニア」「営業」「WEBデザイナー」「WEBディレクター」を積極採用中です!
インターンや新卒採用も行っております。
以下よりご応募をお待ちしております!
https://rightcode.co.jp/recruit
ライトコードの日常12月 1, 2023ライトコードクエスト〜東京オフィス歴史編〜
ITエンタメ10月 13, 2023Netflixの成功はレコメンドエンジン?
ライトコードの日常8月 30, 2023退職者の最終出社日に密着してみた!
ITエンタメ8月 3, 2023世界初の量産型ポータブルコンピュータを開発したのに倒産!?アダム・オズボーン