【Python&Flask】パスワード管理アプリを作ってみる②【DB接続と非同期APIで作るメイン画面】

技術

今回はメイン画面作り

前回、Flaskでパスワード管理アプリを作ろうと決めて、たぶん世界一簡単なログイン画面を作りました。
今回はその続きで、パスワード管理アプリのメインとなる画面を作ります。

前回作った環境の続きに作るので、前回の記事をまだ見てない方は是非そちらを先にチェックしてくださいね。

ということで早速作っていきましょう。

出来上がりの画面イメージはこんな感じです。

ログイン画面と同様にシンプルな画面になりました。

見ての通り、登録しているアカウント情報を一覧表示する画面です。
サービス名とアカウントが表示されています。
パスワードは「******」という固定のテキストで表示し、クリックした時にだけ確認できるようにします。
「変更」をクリックすればデータの変更画面へ遷移します。
また、画面下の「+」アイコンのクリックでデータの新規登録画面へ遷移するイメージです。

まずはモデルづくり

今回はまずデータを登録しておくテーブルをDBに作成するところから始めます。

ちなみにDBへの接続はVSCodeのPostgreSQL拡張を使いました。
接続時の情報は下記の通り。

{
  "label": "pass_mng_db",
  "host": "db",
  "user": "dev",
  "port": 5432,
  "ssl": false,
  "database": "postgres",
  "password": "pass"
}

DBに接続できたら下記のSQLでテーブルを作成します。

DROP TABLE IF EXISTS secret_data;

CREATE TABLE secret_data (
    "id" SERIAL NOT NULL PRIMARY KEY,
    "service" TEXT NOT NULL,
    "account" TEXT NOT NULL,
    "password" TEXT NOT NULL,
    "memo" TEXT,
    "updated_at" TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);

INSERT INTO secret_data (service, account, password, memo)
VALUES
('ツイッター','test-account','ABCD1234','ツイッターアカウント'),
('インスタグラム','insta-test',	'EFG67890', 'https://www.instagram.com/'),
('グーグル', 'test@gmail.com', 'HIJKL123', 'グーグルアカウント'),
('ヤフー', 'test@yahoo.co.jp', '456mnoP', 'ヤフーアカウント'),
('Zoom', 'zoom-test', '7QRstU', 'Zoomの仕事用アカウント'),
('Zoom', 'privae-zoom', '890VWXyz', 'Zoomのプライベート用アカウント');

テーブル名はsecret_dataです。

主キーのidは自動連番になるようにSERIALで定義しています。
更新日時を表すupdated_atDEFAULT CURRENT_TIMESTAMPとして、INSERT時に自動で値をセットするようにしました。

ちなみにpasswordはセキュリティを考慮すれば暗号化して保存するべきですが、現状はひとまず生のパスワードをそのまま保持しています。
暗号化については今後検討することにします。(今回はあくまで仮ですので)

あと、ついでに動作確認用に仮データを6件ほど入れておきました。

テーブルを作成しデータが登録できたら、続いてモデル層を作ります。
Flaskが採用するMTVという設計モデルの M に該当する部分です。

モデル層はアプリケーション内部のデータとビジネスロジックを扱う層です。

ちなみにpythonではORマッパーとしてSQLAlchemyという有名なライブラリがありますが、それほど大規模なDBではないですし今回は使わずに行こうと思います。
SQLも自分で書きますし、フェッチしたデータをオブジェクトにマッピングするところも実装することにします。

ということで先の「secret_data」テーブルを表すモデルは下記のようになりました。

from psycopg2._psycopg import connection
import psycopg2.extras as _ext

class SecretData:
    def __init__(self, data):
        self.id = data["id"]
        self.service = data["service"]
        self.account = data["account"]
        self.password = data["password"]
        self.memo = data["memo"]
        self.updated_at = data["updated_at"]
    
    @staticmethod
    def all(db: connection):
        result = []
        with db.cursor(cursor_factory=_ext.DictCursor) as cur:
            cur.execute("SELECT * FROM secret_data ORDER BY id")
            row = cur.fetchone()
            while row != None:
                result.append(SecretData(row))
                row = cur.fetchone()
        return result

    @staticmethod
    def get(db: connection, id: int):
        with db.cursor(cursor_factory=_ext.DictCursor) as cur:
            cur.execute("SELECT * FROM secret_data WHERE id=%(id)s", { "id": id })
            row = cur.fetchone()
            if row !=None:
                return SecretData(row)
            else:
                return None

「secret_data」というテーブル名に合わせてクラス名はSecretDataとしました。

5~11行目のコンストラクタ(__init__)でDBから取得したデータをSecretDataクラスのフィールドにマッピングしていることがわかると思います。

また、SecretDataクラスにはallgetという2つのメソッドをスタティックで定義しました。
どちらも引数にDBへのコネクションを指定して、DBにsecret_dataをSELECTするメソッドになっています。

allは名前の通り、全SecretDataを取得しています。
今回のパスワード管理アプリではサービス自体のアカウントを管理していないので、全データが取得できていいものとしました。
アカウント毎にデータを管理するのであればログインアカウント等でデータを絞り込む必要がありますね。

getも名前の通りで、引数で指定されたIDを条件に1件のSecretDataを取得するメソッドです。
こちらはデータが取得できなかった場合、Noneを返すようにしました。

allもgetもdb.cursor(cursor_factory=_ext.DictCursor)でカーソルを生成しています。
こうすることでDictionaryの形でデータを取得することができます。

Dictionaryで取得したデータはSecretDataのコンストラクタでフィールドにマッピングされています。

今回はひとまずデータの取得のみ実装しました。
次回以降、登録・更新についても実装する予定です。

メイン画面のViewクラスを作る

モデルができたら次はView層です。
今回作るのはパスワード管理アプリのメイン画面なのでIndexViewクラスとしました。

import psycopg2
from flask import render_template, redirect, session, jsonify
from flask.views import MethodView
from models.secretdata import SecretData

class IndexView(MethodView):
    def get(self):
        if "login" in session and session["login"] == True:
            with psycopg2.connect("postgresql://dev:pass@db:5432/postgres") as db:
                data = SecretData.all(db)
            return render_template("index.html", title="トップ", data = data)
        else:
            return redirect("/login")

    @staticmethod
    def get_password(id: int):
        if "login" in session and session["login"] == True:
            with psycopg2.connect("postgresql://dev:pass@db:5432/postgres") as db:
                data = SecretData.get(db, id)
            if data == None:
                return "", 404
            else:
                return jsonify({"password": data.password})
        else:
            return "", 401

IndexViewクラスにはgetget_passwordという2つのメソッドを実装しました。

getメソッドはrender_templateでindex.htmlのテンプレートをレスポンスするメソッドです。
ログイン済みを確認したら、SecretDataクラスのallで全SecretDataを取得して、dataという変数名でindex.htmlのテンプレートに渡しています。
これでindex.html側で一覧を表示するように実装することができます。(テンプレートの実装については後述します)

get_passwordメソッドは指定されたidをキーにSecretDataを検索し、JSON形式でパスワードだけを返すメソッドです。
データが取得できなければ404(Not Found)、ログインエラーであれば401(Unauthorized)をレスポンスしています。
メイン画面で「******」をクリックした時に非同期でリクエストされることを想定しています。

ちなみにget_passwordメソッドはスタティックメソッドです。
メイン画面からのパスワード取得はGETリクエストにしたいと思っているのですが、MethodViewの派生クラスではGETリクエストはgetというメソッド名で実装しないといけないというルールになっています。
getメソッドはindex.htmlテンプレートをレスポンスする処理としてすでに実装済みなので、パスワード取得をgetメソッドにはできません。
そこでget_passwordというメソッド名でスタティックメソッドとして実装し、app.pyで直接このメソッドにURLを割り当てることにしました。

本当はget_passwordを無理にIndexViewクラスに実装する必要はないかもしれません。
なんとなくメイン画面の機能なのでここに実装しましたが、これがBestなのかはよくわかりません。

で、最後にapp.pyを下記のように変更します。

from flask import Flask, redirect, session
from loginview import LoginView
from indexview import IndexView # 追加
from datetime import timedelta

app = Flask(__name__)

# セッション準備
app.secret_key = 'AopWe-To$eu44r0l'
app.permanent_session_lifetime = timedelta(minutes=20) 

def register_routes():
    # ログイン画面
    app.add_url_rule("/login", view_func=LoginView.as_view("login"))
    #### 追加:ここから ####
    # メイン画面
    indexView = IndexView.as_view("index")
    app.add_url_rule("/", view_func=indexView)
    app.add_url_rule("/index", view_func=indexView)
    app.add_url_rule("/password/<int:id>", view_func=IndexView.get_password)
    #### 追加:ここまで ####

#### コメントアウト:ここから(削除予定) ####
# @app.route("/")
# def index():
#     if "login" in session and session["login"] == True:
#         return "<h1>ログイン成功</h1>"
#     else:
#         return redirect("/login")
#### コメントアウト:ここまで ####

@app.route("/logout")
def logout():
    session["login"] = False
    return redirect("/login")

register_routes()

if __name__ == "__main__":
    app.run(debug=True, host="0.0.0.0")

修正箇所のメインはregister_routesで、メイン画面のルーティングを追加しています。

「/」と「/index」がメイン画面にルーティングされています。
加えてスタティックメソッドとして定義したIndexView.get_passwordは「/password/<int:id>」というURLにルーティングされています。
URLに<int:id>という定義があるので、idをパスパラメータとして指定できることになります。

あとは前回作ったindex()という関数は必要なくなったのでコメントアウトしました。
もちろん、コメントアウトではなく削除してしまってもかまいません。

index.htmlテンプレートを作る

最後はTemplate層ですが、まずscript.jsを作るところから始めます。

script.jsにはいろいろな画面で使われる共通のスクリプトを定義しておきます。

function getApi(url) {
    return new Promise((resolve, reject) => {
        let xhr = new XMLHttpRequest();
        xhr.open('GET', url, true);
        xhr.responseType = 'json';
        xhr.send(null);
        xhr.onload = function(e) {
            if (xhr.status == 200) {
                resolve(xhr.response);
            } else {
                reject(new Error(xhr.status));
            }
        };
        xhr.onerror = function(e) {
            reject(new Error(xhr.status));
        };
    });
};

今回は非同期でGETリクエストする処理getApiを定義しました。

このscript.jsが各画面で読み込まれるように「_layout.html」を修正します。

<!DOCTYPE html>
{% set APP_NAME = "PASSマネ" %}
<html>
    <head>
        <title>{{title}} - {{APP_NAME}}</title>
        <link rel="stylesheet" href="/static/css/style.css">
        <script src="/static/js/script.js"></script><!-- 追加 -->
    </head>
    <body>
        <div class="header">
            <div class="content">
                <label style="margin-top: auto; margin-bottom: auto;">{{APP_NAME}}</label>
            </div>
        </div>
        <div class="main">
            {% block main %}
            {% endblock %}
        </div>
    </body>
</html>

新規追加のアイコンも忘れずに
「static/img/plus.png」に保存しておきます。
plus.pngファイルはここからダウンロードしました。

plus.png

次に前回作ったstyle.cssを編集します。

・・・・(前回のstyle.cssの続き)・・・・

/* トップページ用スタイル */
table {
    margin: auto;
    border: solid 1px black;
}

table th {
    background-color: #c4d3f5;
    border: solid 1px black;
}

table td {
    border: solid 1px black;
    padding: 5px 10px;
    min-width: 120px;
}

table td.small {
    min-width: 80px;
}

table td.center {
    text-align: center;
}

div.plus {
    width: 60px;
    height: 60px;
    background-color: #d6d6f7;
    display: flex;
    border-radius: 25px;
}
div.plus img {
    width: 80%;
    margin: auto;
}

最後にindex.htmlを作ります。

{% extends '_layout.html' %}
{% block main %}
    <script>
        function openPass(eventSource) {
            let txt = document.getElementById('txtDispPass');
            if (txt == null) {
                txt = document.createElement('input');
                txt.id = 'txtDispPass';
                txt.readOnly = true;
            } else {
                txt.parentNode.firstChild.style.display = 'inline';
                txt.remove();
            }
            getApi(`/password/${eventSource.dataset.id}`)
                .then((response) => {
                    txt.value = response.password;
                    txt.style.width = `${eventSource.offsetWidth}px`;
                    eventSource.parentNode.appendChild(txt);
                    eventSource.style.display = 'none';
                })
                .catch((status) => {
                    alert(`失敗:${status}`);
                });
        }
    </script>
    <h1>アカウント情報</h1>
    <table rules="none" style="padding: 5px;">
        <thead>
            <tr><th>サービス</th><th>アカウント</th><th>パスワード</th><th></th></tr>
        </thead>
        <tbody>
            {% for record in data %}
            <tr>
                <td>{{record.service}}</td>
                <td>{{record.account}}</td>
                <td><a href="javascript:void(0)" onclick="openPass(this);" data-id="{{record.id}}">******</a></td>
                <td class="small center"><a href="/edit/{{record.id}}">変更</a></td>
            </tr>
            {% endfor %}
        </tbody>
    </table>
    <a href="/new">
        <div class="plus" style="position: fixed; bottom: 50px; right: calc(50vw - 250px);">
            <img src="/static/img/plus.png">
        </div>
    </a>
{% endblock %}

パスワードの「******」のonclickでopenPassというfunctionを実行しています。

openPassではデータのIDを使って「/password/{id}」というURLを作って、script.jsで定義しておいてgetApi()を使ってGETリクエストをサーバーに投げています。
getApiでパスワードが返ってきたら、「******」の代わりにパスワードを表示しています。

他には「変更」のリンクのURLを「/edit/{id}」、「+」アイコンのリンクを「/new」としています。
このURLに該当する処理はまだ実装していないので404エラーになるはずです。
このURLの実装はまた次回の予定です。

まとめ

ということで、今回は前回の続きでログイン後に表示されるメイン画面を作ってみました。

メイン画面ではDBアクセスして取得したデータを一覧表示しました。
パスワードを非同期APIで取得する仕組みも作ってみました。
ここからさらに絞込み機能なんかを追加してみてもいいかもしれません。

次回はデータの登録/編集画面を作ろうと思います。
また、DB接続情報やログインチェックの処理をあちこちでコピペしているのでそのあたりの整理もやっていこうと思っています。

興味ある方はぜひ次回の記事もご覧いただけると嬉しいです。

追記:2022/10/23
第3回を公開しました。

コメント

タイトルとURLをコピーしました