[Web小説を管理するアプリ] No. 04 : Flaskを使った小説データベース・管理画面の作成

はじめに

本記事は次の連載の一部です。

No. 04ではFlaskを使って小説のデータベース管理画面を作成することを目標としています。

以下ディレクトリ構成です。

.
├── .devcontainer
├── argon-dashboard-master
├── flask_app
│   ├── models
│   │   └── novel_info.py
│   ├── static
│   ├── templates
│   ├── __init__.py
│   ├── config.py
│   └── views.py
├── instance (自動生成) 
├── scraping_modules
├── init_db.py
└── secret_info.json

flaskアプリのディレクトリ構成はいろいろな種類がありますが、今回は、モジュールをflask_appというディレクトリにまとめることにしました。

この場合は、一般的に使われるapp.pyやrun.pyの内容を__init__.pyに各記法です。

また、WebのUIはargon-dashboardから持ってきました。adminliteと迷いましたが、気分的にargonだったので、そうしました。これは好きなdashboardテンプレートを選んでください。

参考にするときに開くのが便利なので、argon-dashboard-masterをプロジェクトディレクトリに入れていますが、flaskのWeb画面として参照するhtmlは、staticやtemplatesにいれます。

データベースの初期化のためのスクリプトinit_db.pyを用意しました。これを実行すると、instanceディレクトリが生成され、データベースファイルが作られます。

Argon Dashboard 2

今回のWebのテンプレートはArgon Dashboard 2を選びました。これはMITライセンスで配布されている無料のテンプレートです。

Argon Dashboard 2 by Creative Tim
Download Argon a free Dashboard for Bootstrap 5 and developed by Creative Tim. Over 70 components, see the live demo on ...

なんとなく、丸いUIが良いかなと思ったので、これにしました。好きなBootstrapのテンプレートを選ぶと良いと思います。

ダウンロードしたテンプレートは解凍して、適当なディレクトリに移します。今回は、プロジェクトのディレクトリに移動させました。

中身のindex.htmlを開くと、デモ画面が表示されます。

ログインとかの機能は実装しませんが、こんな雰囲気の小説管理Web UIが作れることを想像しておきます。

FlaskでのWeb UIの実装

Flaskはテンプレートと呼ばれる機能を使って、Flask側でhtmlファイルを生成し、アクセスしてきたURLに返す仕組みを採用しています。

ですから、そのままArgon dashboardをFlask内にコピーしても、Flask側ではindex.htmlを読み取ってはくれません。

まずは、flaskアプリを生成するプログラムを書きます。これは__init__.pyに書いておくことで、flask_appディレクトリをpythonで読み込むたびに、利用できるようになります。

from flask import Flask
from flask_sqlalchemy import SQLAlchemy

app = Flask(__name__)
app.config.from_object('flask_app.config')

import flask_app.views

また、同ディレクトリにあるviews.pyを読み込むことで、URLによるリクエストに対して何を返すかを記述します。

一般的なFlaskのチュートリアルでは、appの生成とviews.pyでのリクエスト関数は同じファイルで記述されますが、別ファイルに分けることで、管理がしやすくなります。

from flask import Flask, render_template, url_for
from flask_app import app

@app.route('/')
def index():
    return render_template('dashboard.html')

@app.route('/novel_list')
def novel_list():
    return render_template('novel_list.html')

@app.route('/save')
def save():
    return render_template('save.html')

@app.route('/analysis')
def analysis():
    return render_template('analysis.html')

@app.route('/recommend')
def recommend():
    return render_template('recommend.html')

@app.route('/settings_general')
def settings_general():
    return render_template('settings_general.html')

url_forを利用しないと、Web UI側でサイドバーの画面遷移ができないので、インポートしておきます。

views.pyでは、「ルート、小説リスト、保存、分析、推薦、設定画面」の6つのURLに対して、返答するhtmlをここで決めておきます。

また、flaskの設定をconfig.pyに追記できます。

DEBUG = True
SQLALCHEMY_DATABASE_URI = 'sqlite:///novel_manager.db'
SQLALCHEMY_TRACK_MODIFICATIONS = True

ここでは小説のデータベースのファイルを設定していますが、この記事ではまだ、データベースを利用しません。

views.pyを書き終わったら、それぞれの関数が呼び出すhtmlを作成していきます。Flaskではデフォルトでtemplatesディレクトリにあるhtmlを読み込みますので、templatesディレクトリを作り、そこに各種のhtmlを配置します。

さらに各htmlが読み込むassets(jsやcssなど)はstaticに配置します。

配置予定は以下のとおりです。

├── flask_app
...
│   ├── static
│   │   ├── css
│   │   ├── fonts
│   │   ├── img
│   │   ├── js
│   │   └──  scss
│   ├── templates
│   │   ├── analysis.html
│   │   ├── base.html
│   │   ├── dashboard.html
│   │   ├── novel_list.html
│   │   ├── recommend.html
│   │   ├── save.html
│   │   └── settings_general.html
...

staticへのファイル配置

staticにはcssやjavascriptなどの固定要素を配置します。

これは、自分が用意したテンプレートが読み込むファイルを配置すればよいです。

今回は、Argon Dashboard 2の中にあった、assets以下のディレクトリをそのままコピーしました。

base.htmlの実装

共通するhtmlを記述するbase.htmlを作成します。以下に今回作成したbase.htmlを記述しておきます。

<!--
=========================================================
* Argon Dashboard 2 - v2.0.4
=========================================================

* Product Page: https://www.creative-tim.com/product/argon-dashboard
* Copyright 2022 Creative Tim (https://www.creative-tim.com)
* Licensed under MIT (https://www.creative-tim.com/license)
* Coded by Creative Tim

=========================================================

* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
-->
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
  <link rel="apple-touch-icon" sizes="76x76" href="./static/img/apple-icon.png">
  <link rel="icon" type="image/png" href="./static/img/favicon.png">
  <title>
    Argon Dashboard 2 by Creative Tim
  </title>
  <!--     Fonts and icons     -->
  <link href="https://fonts.googleapis.com/css?family=Open+Sans:300,400,600,700" rel="stylesheet" />
  <!-- Nucleo Icons -->
  <link href="./static/css/nucleo-icons.css" rel="stylesheet" />
  <link href="./static/css/nucleo-svg.css" rel="stylesheet" />
  <!-- Font Awesome Icons -->
  <script src="https://kit.fontawesome.com/42d5adcbca.js" crossorigin="anonymous"></script>
  <link href="./static/css/nucleo-svg.css" rel="stylesheet" />
  <!-- CSS Files -->
  <link id="pagestyle" href="./static/css/argon-dashboard.css?v=2.0.4" rel="stylesheet" />
</head>

<body class="g-sidenav-show   bg-gray-100">
  <div class="min-height-300 bg-primary position-absolute w-100"></div>
  <aside class="sidenav bg-white navbar navbar-vertical navbar-expand-xs border-0 border-radius-xl my-3 fixed-start ms-4 " id="sidenav-main">
    <div class="sidenav-header">
      <i class="fas fa-times p-3 cursor-pointer text-secondary opacity-5 position-absolute end-0 top-0 d-none d-xl-none" aria-hidden="true" id="iconSidenav"></i>
      <a class="navbar-brand m-0" target="_blank">
        <span class="ms-1 font-weight-bold">Novel Manager</span>
      </a>
    </div>
    <hr class="horizontal dark mt-0">
    <div class="collapse navbar-collapse  w-auto " id="sidenav-collapse-main">
      <ul class="navbar-nav">
        <li class="nav-item">
          <a class="nav-link " href="{{url_for('index')}}">
            <div class="icon icon-shape icon-sm border-radius-md text-center me-2 d-flex align-items-center justify-content-center">
              <i class="ni ni-app text-primary text-sm opacity-10"></i>
            </div>
            <span class="nav-link-text ms-1">Dashboard</span>
          </a>
        </li>
        <li class="nav-item">
          <a class="nav-link " href="{{url_for('novel_list')}}">
            <div class="icon icon-shape icon-sm border-radius-md text-center me-2 d-flex align-items-center justify-content-center">
              <i class="ni ni-bullet-list-67 text-warning text-sm opacity-10"></i>
            </div>
            <span class="nav-link-text ms-1">Novel List</span>
          </a>
        </li>
        <li class="nav-item">
          <a class="nav-link " href="{{url_for('save')}}">
            <div class="icon icon-shape icon-sm border-radius-md text-center me-2 d-flex align-items-center justify-content-center">
              <i class="ni ni-folder-17 text-success text-sm opacity-10"></i>
            </div>
            <span class="nav-link-text ms-1">Save</span>
          </a>
        </li>
        <li class="nav-item">
          <a class="nav-link " href="{{url_for('analysis')}}">
            <div class="icon icon-shape icon-sm border-radius-md text-center me-2 d-flex align-items-center justify-content-center">
              <i class="ni ni-chart-bar-32 text-info text-sm opacity-10"></i>
            </div>
            <span class="nav-link-text ms-1">Analysis</span>
          </a>
        </li>
        <li class="nav-item">
          <a class="nav-link " href="{{url_for('recommend')}}">
            <div class="icon icon-shape icon-sm border-radius-md text-center me-2 d-flex align-items-center justify-content-center">
              <i class="ni ni-like-2 text-danger text-sm opacity-10"></i>
            </div>
            <span class="nav-link-text ms-1">Recommend</span>
          </a>
        </li>
        <li class="nav-item mt-3">
          <h6 class="ps-4 ms-2 text-uppercase text-xs font-weight-bolder opacity-6">Settings</h6>
        </li>
        <li class="nav-item">
          <a class="nav-link " href="{{url_for('settings_general')}}">
            <div class="icon icon-shape icon-sm border-radius-md text-center me-2 d-flex align-items-center justify-content-center">
              <i class="ni ni-settings-gear-65 text-dark text-sm opacity-10"></i>
            </div>
            <span class="nav-link-text ms-1">General</span>
          </a>
        </li>
      </ul>
    </div>
  </aside>
  {% block main %}
  {% endblock %}
  <div class="fixed-plugin">
    <a class="fixed-plugin-button text-dark position-fixed px-3 py-2">
      <i class="fa fa-cog py-2"> </i>
    </a>
    <div class="card shadow-lg">
      <div class="card-header pb-0 pt-3 ">
        <div class="float-start">
          <h5 class="mt-3 mb-0">Argon Configurator</h5>
          <p>See our dashboard options.</p>
        </div>
        <div class="float-end mt-4">
          <button class="btn btn-link text-dark p-0 fixed-plugin-close-button">
            <i class="fa fa-close"></i>
          </button>
        </div>
        <!-- End Toggle Button -->
      </div>
      <hr class="horizontal dark my-1">
      <div class="card-body pt-sm-3 pt-0 overflow-auto">
        <!-- Sidebar Backgrounds -->
        <div>
          <h6 class="mb-0">Sidebar Colors</h6>
        </div>
        <a href="javascript:void(0)" class="switch-trigger background-color">
          <div class="badge-colors my-2 text-start">
            <span class="badge filter bg-gradient-primary active" data-color="primary" onclick="sidebarColor(this)"></span>
            <span class="badge filter bg-gradient-dark" data-color="dark" onclick="sidebarColor(this)"></span>
            <span class="badge filter bg-gradient-info" data-color="info" onclick="sidebarColor(this)"></span>
            <span class="badge filter bg-gradient-success" data-color="success" onclick="sidebarColor(this)"></span>
            <span class="badge filter bg-gradient-warning" data-color="warning" onclick="sidebarColor(this)"></span>
            <span class="badge filter bg-gradient-danger" data-color="danger" onclick="sidebarColor(this)"></span>
          </div>
        </a>
        <!-- Sidenav Type -->
        <div class="mt-3">
          <h6 class="mb-0">Sidenav Type</h6>
          <p class="text-sm">Choose between 2 different sidenav types.</p>
        </div>
        <div class="d-flex">
          <button class="btn bg-gradient-primary w-100 px-3 mb-2 active me-2" data-class="bg-white" onclick="sidebarType(this)">White</button>
          <button class="btn bg-gradient-primary w-100 px-3 mb-2" data-class="bg-default" onclick="sidebarType(this)">Dark</button>
        </div>
        <p class="text-sm d-xl-none d-block mt-2">You can change the sidenav type just on desktop view.</p>
        <!-- Navbar Fixed -->
        <div class="d-flex my-3">
          <h6 class="mb-0">Navbar Fixed</h6>
          <div class="form-check form-switch ps-0 ms-auto my-auto">
            <input class="form-check-input mt-1 ms-auto" type="checkbox" id="navbarFixed" onclick="navbarFixed(this)">
          </div>
        </div>
        <hr class="horizontal dark my-sm-4">
        <div class="mt-2 mb-5 d-flex">
          <h6 class="mb-0">Light / Dark</h6>
          <div class="form-check form-switch ps-0 ms-auto my-auto">
            <input class="form-check-input mt-1 ms-auto" type="checkbox" id="dark-version" onclick="darkMode(this)">
          </div>
        </div>

      </div>
    </div>
  </div>
  <!--   Core JS Files   -->
  <script src="./static/js/core/popper.min.js"></script>
  <script src="./static/js/core/bootstrap.min.js"></script>
  <script src="./static/js/plugins/perfect-scrollbar.min.js"></script>
  <script src="./static/js/plugins/smooth-scrollbar.min.js"></script>
  <script src="./static/js/plugins/chartjs.min.js"></script>
  <script>
    var ctx1 = document.getElementById("chart-line").getContext("2d");

    var gradientStroke1 = ctx1.createLinearGradient(0, 230, 0, 50);

    gradientStroke1.addColorStop(1, 'rgba(94, 114, 228, 0.2)');
    gradientStroke1.addColorStop(0.2, 'rgba(94, 114, 228, 0.0)');
    gradientStroke1.addColorStop(0, 'rgba(94, 114, 228, 0)');
    new Chart(ctx1, {
      type: "line",
      data: {
        labels: ["Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"],
        datasets: [{
          label: "Mobile apps",
          tension: 0.4,
          borderWidth: 0,
          pointRadius: 0,
          borderColor: "#5e72e4",
          backgroundColor: gradientStroke1,
          borderWidth: 3,
          fill: true,
          data: [50, 40, 300, 220, 500, 250, 400, 230, 500],
          maxBarThickness: 6

        }],
      },
      options: {
        responsive: true,
        maintainAspectRatio: false,
        plugins: {
          legend: {
            display: false,
          }
        },
        interaction: {
          intersect: false,
          mode: 'index',
        },
        scales: {
          y: {
            grid: {
              drawBorder: false,
              display: true,
              drawOnChartArea: true,
              drawTicks: false,
              borderDash: [5, 5]
            },
            ticks: {
              display: true,
              padding: 10,
              color: '#fbfbfb',
              font: {
                size: 11,
                family: "Open Sans",
                style: 'normal',
                lineHeight: 2
              },
            }
          },
          x: {
            grid: {
              drawBorder: false,
              display: false,
              drawOnChartArea: false,
              drawTicks: false,
              borderDash: [5, 5]
            },
            ticks: {
              display: true,
              color: '#ccc',
              padding: 20,
              font: {
                size: 11,
                family: "Open Sans",
                style: 'normal',
                lineHeight: 2
              },
            }
          },
        },
      },
    });
  </script>
  <script>
    var win = navigator.platform.indexOf('Win') > -1;
    if (win && document.querySelector('#sidenav-scrollbar')) {
      var options = {
        damping: '0.5'
      }
      Scrollbar.init(document.querySelector('#sidenav-scrollbar'), options);
    }
  </script>
  <!-- Github buttons -->
  <script async defer src="https://buttons.github.io/buttons.js"></script>
  <!-- Control Center for Soft Dashboard: parallax effects, scripts for the example pages etc -->
  <script src="./static/js/argon-dashboard.min.js?v=2.0.4"></script>
</body>

</html>

これは、Argon dashboardのindex.htmlから次の修正を加えたものです。

  • <main>タグの範囲を切り取る
    • <main>タグがあった場所に{% block main %} {% endblock %}を入れる
  • サイドバーアイテム、<li class=”nav-item”>のactiveを消す
    • 現状だとactive状態がサイドバーアイテムをクリックしても変更しないので、とりあえず削除。そのうち変動するように修正します。
  • 各サイドバーアイテム、<li class=”nav-item”>における、hrefを”{{url_for(‘any func name’)}}”へ変更する。
    • これは、flaskのviewsで定義した関数を呼び出す記述です。
  • 各サイドバーアイテム、<li class=”nav-item”>におけるアイコンを変更する。
  • javascriptやcssの読み込みディレクトリを変更する
    • “./assets/”を”./static/”へと一括変換すると楽です。

基本的にこれをベースにして、htmlを生成しURLに対して返答します。ですので、全ページに共通するサイドバーの要素をここで実装しました。

各種htmlファイルの実装

dashboard.htmlではトップページの実装を行います。

とはいえ、現時点では何も配置しないので、以下のようにトップバーだけ実装してページ遷移がわかるようにしておきます。

{% extends 'base.html' %}
{% block main %}
<main class="main-content position-relative border-radius-lg ">
    <nav class="navbar navbar-main navbar-expand-lg px-0 mx-4 shadow-none border-radius-xl " id="navbarBlur" data-scroll="false">
        <div class="container-fluid py-1 px-3">
          <nav aria-label="breadcrumb">
            <ol class="breadcrumb bg-transparent mb-0 pb-0 pt-1 px-0 me-sm-6 me-5">
              <li class="breadcrumb-item text-sm"><a class="opacity-5 text-white">Pages</a></li>
              <li class="breadcrumb-item text-sm text-white active" aria-current="page">Dashboard</li>
            </ol>
            <h6 class="font-weight-bolder text-white mb-0">Dashboard</h6>
          </nav>
        </div>
      </nav>
</main>
{% endblock %}

ちなみに、以下、novel_list.html、save.html、analysis.html、recommend.html、settings_general.htmlでも同様です。各種のページの実装はこの章の各節にて行います。

{% extends 'base.html' %}
{% block main %}
<main class="main-content position-relative border-radius-lg ">
    <nav class="navbar navbar-main navbar-expand-lg px-0 mx-4 shadow-none border-radius-xl " id="navbarBlur" data-scroll="false">
        <div class="container-fluid py-1 px-3">
          <nav aria-label="breadcrumb">
            <ol class="breadcrumb bg-transparent mb-0 pb-0 pt-1 px-0 me-sm-6 me-5">
              <li class="breadcrumb-item text-sm"><a class="opacity-5 text-white">Pages</a></li>
              <li class="breadcrumb-item text-sm text-white active" aria-current="page">Novel List</li>
            </ol>
            <h6 class="font-weight-bolder text-white mb-0">Novel List</h6>
          </nav>
        </div>
      </nav>
</main>
{% endblock %}
{% extends 'base.html' %}
{% block main %}
<main class="main-content position-relative border-radius-lg ">
    <nav class="navbar navbar-main navbar-expand-lg px-0 mx-4 shadow-none border-radius-xl " id="navbarBlur" data-scroll="false">
        <div class="container-fluid py-1 px-3">
          <nav aria-label="breadcrumb">
            <ol class="breadcrumb bg-transparent mb-0 pb-0 pt-1 px-0 me-sm-6 me-5">
              <li class="breadcrumb-item text-sm"><a class="opacity-5 text-white">Pages</a></li>
              <li class="breadcrumb-item text-sm text-white active" aria-current="page">Save</li>
            </ol>
            <h6 class="font-weight-bolder text-white mb-0">Save</h6>
          </nav>
        </div>
      </nav>
</main>
{% endblock %}
{% extends 'base.html' %}
{% block main %}
<main class="main-content position-relative border-radius-lg ">
    <nav class="navbar navbar-main navbar-expand-lg px-0 mx-4 shadow-none border-radius-xl " id="navbarBlur" data-scroll="false">
        <div class="container-fluid py-1 px-3">
          <nav aria-label="breadcrumb">
            <ol class="breadcrumb bg-transparent mb-0 pb-0 pt-1 px-0 me-sm-6 me-5">
              <li class="breadcrumb-item text-sm"><a class="opacity-5 text-white">Pages</a></li>
              <li class="breadcrumb-item text-sm text-white active" aria-current="page">Analysis</li>
            </ol>
            <h6 class="font-weight-bolder text-white mb-0">Analysis</h6>
          </nav>
        </div>
      </nav>
</main>
{% endblock %}
{% extends 'base.html' %}
{% block main %}
<main class="main-content position-relative border-radius-lg ">
    <nav class="navbar navbar-main navbar-expand-lg px-0 mx-4 shadow-none border-radius-xl " id="navbarBlur" data-scroll="false">
        <div class="container-fluid py-1 px-3">
          <nav aria-label="breadcrumb">
            <ol class="breadcrumb bg-transparent mb-0 pb-0 pt-1 px-0 me-sm-6 me-5">
              <li class="breadcrumb-item text-sm"><a class="opacity-5 text-white">Pages</a></li>
              <li class="breadcrumb-item text-sm text-white active" aria-current="page">Recommend</li>
            </ol>
            <h6 class="font-weight-bolder text-white mb-0">Recommend</h6>
          </nav>
        </div>
      </nav>
</main>
{% endblock %}
{% extends 'base.html' %}
{% block main %}
<main class="main-content position-relative border-radius-lg ">
    <nav class="navbar navbar-main navbar-expand-lg px-0 mx-4 shadow-none border-radius-xl " id="navbarBlur" data-scroll="false">
        <div class="container-fluid py-1 px-3">
          <nav aria-label="breadcrumb">
            <ol class="breadcrumb bg-transparent mb-0 pb-0 pt-1 px-0 me-sm-6 me-5">
              <li class="breadcrumb-item text-sm"><a class="opacity-5 text-white">Pages</a></li>
              <li class="breadcrumb-item text-sm text-white active" aria-current="page">Setting General</li>
            </ol>
            <h6 class="font-weight-bolder text-white mb-0">Setting General</h6>
          </nav>
        </div>
      </nav>
</main>
{% endblock %}

実行

各プログラムを配置したら、flaskアプリを起動させます。

デフォルトではFLASK_APP環境変数を参照して、実行されます。今回は、DockerfileにENV FLASK_APP=flask_appと書いておいたので、flask_appディレクトリ(__init__.py)をデフォルトで参照します。

プロジェクトディレクトリで以下のコマンドを実行すると、flaskアプリが起動します。

vscode@novel_management_service-container:/work$ flask run

正常に起動すれば、アクセスするURLが書かれたログが流れます。

 * Serving Flask app 'flask_app'
 * Debug mode: on
WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
 * Running on http://127.0.0.1:5000
Press CTRL+C to quit
 * Restarting with stat
 * Debugger is active!
 * Debugger PIN: 630-684-963

開発サーバーだから注意しろみたいな警告が出ていますが、個人で使う分には問題ないので無視します。

ブラウザから「http://127.0.0.1:5000」へアクセスして、Web UIを確かめます。

ちゃんとWebのダッシュボードが表示されました。

サイドバーの要素をクリックして、ページが変わることを確認しておきます。

ページの読み込みエラーなどが無いことを確認したら、これでFlaskでの基本的なWeb UIの実装は終了です。あとは、各ページとデータベース、およびそれに伴う処理を追加していけば、Webアプリが作成できます。

Section 04-01では、データベースの作成と小説リストのページを作っていきます。


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