• トップ
  • ブログ一覧
  • 【第8回】Responder + Firestore でモダンかつサーバーレスなブログシステムを作ってみた【管理者ページの仕上げ】
  • 【第8回】Responder + Firestore でモダンかつサーバーレスなブログシステムを作ってみた【管理者ページの仕上げ】

    広告メディア事業部広告メディア事業部
    2020.08.13

    IT技術

    第8回~モダンなフレームワークの使い方を学びながらブログシステムを構築~

    連載「Python Responder + Firestore でモダンかつサーバーレスなブログシステムを作ってみる」第8回目です。

    前回は、「メディアのアップロード機能」を実装しました。

    featureImg2020.08.12【第7回】Responder + Firestore でモダンかつサーバーレスなブログシステムを作ってみた【メディア管理と非同期処理】第7回~モダンなフレームワークの使い方を学びながらブログシステムを構築~連載「Python Responder + F...

    管理者ページの整備を完成させよう

    今回行うのは、「管理者ページの整備の仕上げ」です。

    具体的には、以下の4点を実装していきます。

    1. プロフィール変更
    2. カテゴリの追加と修正
    3. タグの追加と修正
    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']

    内容は、前に書いた「ユーザ取得関数」と大きな違いはありません。

    これも、「辞書型変数」に直して、必要なデータだけ受け取るようにします。

    管理者情報のアップデート処理のルーティング

    では、実際に処理を書いていきましょう。

    実装する処理内容は、以下の通りです。

    1. POSTデータを取得
    2. POSTデータに誤りがないかチェック
    3. 更新
    4. エラーがあればエラー処理
    5. なければ更新してクッキーも更新

    さっそく実装してみます。

    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つだけなので、何も難しいことはありません。

    1. 「/admin/new」なら、input value 要素には何も入れない
    2. 「/admin/edit/{slug} 」なら、記事の要素を input value要素に入れる + α

    ルーティング

    以前実装したget_article(slug) で、記事をコントローラ側で取得し、ビューに渡すだけです。

    このとき、ビューでの POST 先は、以下のように設定します。

    1. 新規追加なら「/admin/add」
    2. 記事更新なら「/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',  # POSTURL
    14                                 slug=slug,
    15                                 article=article,  # 現在の記事情報
    16                                 name=req.cookies.get('username'),
    17                                 categories=categories,
    18                                 tags=tags
    19                                 )

    new.htmlの修正

    次に、記事を展開するために、ビューを修正します。

    修正点は以下の3点です。

    1. 空だった value の中身を変更
    2. POST 先 URL を追加
    3. カテゴリのチェックボックスを 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        # マークダウンからHTML21        # 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回へつづく!

    今回は長い道のりでしたが、一気に管理者ページを仕上げることができました。

    あとは、ブログを表示する「フロント側の実装」がメインになります。

    もう終盤戦です、このまま頑張っていきましょう!

    次回の記事はこちら

    featureImg2020.08.17【第9回】Responder + Firestore でモダンかつサーバーレスなブログシステムを作ってみた【ブログトップを作る】第9回~モダンなフレームワークの使い方を学びながらブログシステムを構築~連載「Python Responder+Fir...

    第1回はこちら

    featureImg2020.07.17【第1回】Responder + Firestore でモダンかつサーバーレスなブログシステムを作ってみた【初期セットアップ編】モダンなフレームワークの使い方を学びながらブログシステムを構築今回より、また新たに Python WebAPI 関連の...

    こちらの記事もオススメ!

    featureImg2020.07.17ライトコード的「やってみた!」シリーズ「やってみた!」を集めました!(株)ライトコードが今まで作ってきた「やってみた!」記事を集めてみました!※作成日が新し...

    featureImg2020.07.30Python 特集実装編※最新記事順Responder + Firestore でモダンかつサーバーレスなブログシステムを作ってみた!P...

    広告メディア事業部

    広告メディア事業部

    おすすめ記事