• トップ
  • ブログ一覧
  • 【第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...

    ライトコードでは、エンジニアを積極採用中!

    ライトコードでは、エンジニアを積極採用しています!社長と一杯しながらお話しする機会もご用意しております。そのほかカジュアル面談等もございますので、くわしくは採用情報をご確認ください。

    採用情報へ

    広告メディア事業部

    広告メディア事業部

    おすすめ記事

    エンジニア大募集中!

    ライトコードでは、エンジニアを積極採用中です。

    特に、WEBエンジニアとモバイルエンジニアは是非ご応募お待ちしております!

    また、フリーランスエンジニア様も大募集中です。

    background