2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Flask + PostgreSQLで学習支援アプリ開発!Docker環境構築に挑戦

2
Last updated at Posted at 2026-02-02

1.課題作成の目的

これまでのPython学習で得た知識をアウトプットする事を目的としています。
Python Flask と PostgreSQL を組み合わせた、リレーショナルデータベース(RDB)の設計・操作スキルの習得を主眼に置いています。
自身の学習過程で得た技術的な知見を「記憶メモ」として整理する役割も兼ねて、詳細説明を行っています。
ここでの説明だけでは分かりにくい面もあるので、読者の皆様にも実践できる様に、Docker環境の構築にも挑戦しましたので、興味のある方は使用して頂ければと思っています。

◆構成
1.課題作成の目的
2.アプリ概要
3.環境
4.クイックスタート(使い方)
5.機能(技術説明)
6.Docker環境の構築
7.まとめ
8.基本操作マニュアル
9.全コード(GitHubリポジトリ公開中)

2.アプリ概要

本アプリは、PostgreSQLのリレーショナル特性を活かし、学習内容の「時間」「資料」というデータを記録していき、後日に見える形に出力する事で、振り返りの質を高めることを目的としています。

📥 入力(インプット)

リレーショナルデータベースを活かした学習実績の登録を行います。

  • 基本情報の登録
    • 学習実績の「タイトル」、「詳細内容」を登録。
    • 「学習カテゴリ」と「学習時間」を紐付けて登録。
  • 参考情報の保存
    • 学習に使用した参考URLに対し、「カテゴリー」、「自己評価」を付与して保存。
  • コミュニケーション
    • 「いいね」、「コメント」によりユーザ同士の交流。
  • 統合的な管理
    • 管理者がユーザー情報、カテゴリ情報、デモストレーションデータを一括管理する。

📊 出力(アウトプット):実績の振返り

蓄積データの視覚的なフィードバックを用意しています。

1. 学習状況の可視化(ビジュアライゼーション)

種類 内容 目的
棒グラフ 1ヶ月/1年の学習時間合計 学習の継続性を時間軸で把握
円グラフ カテゴリ別の学習比率 注力度やスキルの偏りを把握

image.png


image.png

2. フィルタリング機能

目的 内容
情報共有 登録した参考資料を、評価やカテゴリーで絞った選別表示
パーソナライズ 特定ユーザーの活動履歴を選択表示

◆作成アプリ

作成アプリは、以下の2バージョンを用意しました。
内容はほぼ同じですが、、動作環境の関係から少しだけ異なっています。
ここからの説明は、「アプリ2(ローカルPC版)」に対して行います。

1. アプリ1(Herokuデプロイ版)

以下のURL に、アプリをデプロイしました。
https://study-support-apl-7e33842a0b97.herokuapp.com/
記事を読みながらインターフェースの感じを、簡単に見て頂ければと思っています。
こちらでは、サンプルデータを入力しているので、結果表示機能の幾つは使用できます。

2. アプリ2(ローカルPC版)

GitHub に登録したデータとなります。
https://github.com/wata123-t/stude-support-apl/
ローカルPCで使用していたものとなり、Herokuへデプロイしたものとは少し異なります。
こちらには、Docker環境を設けたので、コードを編集しながらアプリの動きも見られます。

3.環境

提供環境は Windows 10 にて、Docker Desktop(28.5.2)を使用し動作確認しました。

3.1. 開発インフラ

コンポーネント 技術・ツール 役割
言語・ランタイム Python 3.12 アプリケーションのメインロジック実行
データベース PostgreSQL 学習データ、ユーザー情報の永続化(RDB)
DB管理ツール Adminer 軽量で高速なDB管理用Webインタフェース

3.2. 使用ライブラリ(Python)

ライブラリ バージョン
Flask 3.1.2
Flask-Admin 2.0.2
Flask-Login 0.6.3
Flask-Migrate 4.1.0
psycopg-binary 3.1.18
Flask-SQLAlchemy 3.1.1

3.3 ディレクトリ構成

scr/
├─ app.py(Pythonアプリ)
├─ Dockerfile(アプリケーションコンテナのビルド定義)
├─ compose.yaml(Docker Compose関連の設定ファイル)
├─ requirements.txt(Pythonの依存関係リスト)
├─ static/
│  └─ js
│    └─ JavaScript
├─ templates/
│   └─ HTMLテンプレート
└─ postgres/
   └─ compose.yaml(Docker Compose関連の設定ファイル)

4.クイックスタート(使い方)

これをスタートする前に、Dockerコマンド実行によるアプリ起動を行って下さい。
アプリ起動後の「初期設定」、「学習記録の投稿」、「ダミーデータを生成」、「グラフ表示」までのフローを簡単に説明します。
他の詳細機能は、基本操作マニュアルを参照して下さい。

  1. ログインページから、管理者としてログイン
    ユーザ名:admin
    パスワード:admin
  2. 管理者ページにて、ユーザー登録、カテゴリー登録
  3. 投稿ページにて、各項目を入力し投稿
  4. 管理者ページにて、ダミーデータを生成
  5. 結果出力ページにて、グラフ表示

5.機能(技術説明)

5-1.「ユーザインターフェース設計」(HTML/Jinja2/JS )

Bootstrap機能を利用したレスポンシブな構成とし、JavaScript機能を利用した「柔軟な入力フォーム」、「グラフ表示」を実装しています。
ユーザーインターフェースは Flask の Jinja2テンプレートエンジンを活用し、バックエンドのデータを使いながら動的な表示も実装しています。

ファイル名(.html) 説明
base 全画面の土台。ヘッダーやナビゲーションバーを定義
index ホーム画面としての役割を持ち投稿一覧を表示
readmore 投稿詳細、紐付くコメントやいいねを同時に表示
administrator アプリ管理者が制御を行える機能を実装
create_post 学習実績の入力
dashboard 学習実績の振替えり機能を搭載
dashboard_graph 学習時間の可視化(Chart.jsの活用)
error エラー画面を避け、「ホームに戻る」ボタンを用意
login Flask-Loginと連携したログイン画面
update 既存投稿の修正

フロントエンドに関する技術の勉強が不十分な事もあり、かなりAIの力を借りました。
ただ、それをシッカリ理解する事で、フロントエンド技術を鍛えられたとも感じています。

5-2.データモデル設計

アプリで使用するデータテーブルの一覧を示します。

1. user (ユーザー)

Flask-LoginのUserMixinを継承しており、認証機能と密接に連携しています。

カラム名 (物理名) 制約 説明
id Integer PK ユーザーID
username String(50) Unique, Not Null ユーザー名
password String(255) Not Null ハッシュ化済みパスワード
is_admin Boolean Default(False) 管理者フラグ

「User」,「StudyPost」は1対多で紐付ける事で処理が簡略化できます。

  posts = db.relationship('StudyPost', backref='author', lazy=True)

2. study_category (学習カテゴリー)

カラム名 (物理名) 制約 説明
id Integer Primary Key カテゴリーID
name String(50) Unique, Not Null カテゴリー名

3. study_post (学習投稿メインデータ)

アプリのメイン機能である「投稿モデル(StudyPost)」は、多くのテーブルと紐付くハブのような役割を担っています。

カラム名 (物理名) 制約 説明
id Integer Primary Key 投稿ID
user_id Integer ForeignKey, Not Null 投稿者ID
title String(200) Not Null タイトル
content Text - 内容
created_at DateTime Not Null 投稿日時

ここでは、以下のリレーション設定をして、1対多ので紐付けを行っています。

likes = db.relationship('Like', backref='post', lazy='dynamic', cascade="all, delete-orphan")
comments = db.relationship('Comment', backref='post', lazy='dynamic', cascade="all, delete-orphan")
details = db.relationship('StudyDetail', backref='post', lazy=True, cascade="all, delete-orphan")
references = db.relationship('Reference', backref='post', cascade='all, delete-orphan')

4. study_detail (学習時間明細)

カラム名 (物理名) 制約 説明
id Integer Primary Key 明細ID
post_id Integer ForeignKey, Not Null 紐付く投稿ID
category_id Integer ForeignKey, Not Null カテゴリーID
duration_minutes Integer Not Null 学習時間(分)

5. references (参照資料)

カラム名 (物理名) 制約 説明
id Integer Primary Key 参照ID
post_id Integer ForeignKey, Not Null 投稿ID
title String(200) Not Null 資料名
url String(500) - 参考URL
rating Integer - おすすめ度(1-5)

7. likes (いいね)

「User」と「StudyPost」を繋いでおり、「多対多」の関係を管理しています。

カラム名 (物理名) 制約 説明
id Integer Primary Key いいねID
user_id Integer ForeignKey, Not Null ユーザーID
post_id Integer ForeignKey, Not Null 投稿ID
created_at DateTime Default 実行日時

8. comments (コメント)

カラム名 (物理名) 制約 説明
id Integer Primary Key コメントID
user_id Integer ForeignKey, Not Null 投稿者ID
post_id Integer ForeignKey, Not Null 投稿ID
content Text Not Null コメント内容
created_at DateTime - 投稿日時

5-3. バックエンド:Flask によるデータ保存や読出し

インターフェースの基本構成は以下の様になっています。

image.png

テンプレートの構成

赤枠内は固定され、青枠内がページ内容により更新されます。
継承される側(親)はbase.htmlとなり、それ以外のHTMLは継承する側(子)となります。
そのため、常にヘッダー部やフッター部は base.html の内容が維持され、共通のナビゲーションメニューが表示される仕組みです。

SSR(サーバーサイドレンダリング)の採用

本アプリでは、SSR(サーバーサイドレンダリング) という方式を採用しています。これは、サーバー側(Flask)でHTMLを完成させてからブラウザに送る方式です。ブラウザ側でJavaScriptなどを使って描画するのではなく、サーバー側でデータを埋め込んだ状態でHTMLを出力するため、初期表示の制御がしやすいという特徴があります。
このサーバー側でのHTML生成(レンダリング)を支えているのが、Jinja2 の「テンプレートの継承」という機能です。

base.html(継承される側)
        {% block content %}
        {% endblock %}
index.html(継承する側)
{% extends 'base.html' %}
{% block title %}記録一覧{% endblock %}
{% block content %}
....
....
....
{% endblock %}

この機能を利用して、主要ページに移行できる様になっています。
ナビゲーションメニュー(base.html)から、以下の流れで4個の主要なHTMLファイルに移動でき、HTMLとFlask(app.py)が連携する形で機能します。
FLASK の「ルーティング」機能を利用した、HTMLと Pythonスクリプトの紐付けによる関係性を下図にまとめました。
※ブラウザ側のHTMLは、サーバー側(Flask)で完成させブラウザ側に送られています。

image.png

「ルーティング」機能の設定は、以下の様に行われています。

base.html(エンドポイントの指定)
<a href="/index" class="btn btn-outline-primary">ホーム</a>
<a href="/login" class="btn btn-primary">ログイン</a>
<a href="/dashboard" class="btn btn-outline-primary">結果表示</a>
<a href="/administrator" class="btn btn-outline-primary">管理者専用</a>

接続先となるエンドポイント(/index,/login,/dashboard,/administrator)をHTMLで指定します。

app.py(デコレータ宣言部)
@app.route("/login", methods=['GET','POST'])
def login():

エンドポイント(/index)の接続先を、FLASK(app.py)内で設定し、その下の「def login()」に機能を記述します。

1. トップページ機能

ここでは、これまでの投稿の簡単な内容の「一覧を表示」、「新規投稿」ができます。
一覧にある記事を選択する事で詳細ページへ移動します。
アプリ画面は、こちらを参照して下さい。

app.py(indexのデコレータ部)
@app.route("/")
@app.route("/index")
def index():
    posts = StudyPost.query.order_by(StudyPost.created_at.desc()).all()
    return render_template("index.html",posts=posts)

base.htmlからの選択でindex.htmlへ遷移した後、以下のページ遷移を選択できます。

No ファイル(.html) 機能 参照
1 create_post 投稿内容をデータベースに保存 2. 投稿機能
2 readmore 記事の詳細表示/更新/削除 3. 詳細表示機能

※ブラウザ側のHTMLは、サーバー側(Flask)で完成させブラウザ側に送られています。
image.png

2. 投稿機能

学習実績に関して、以下の内容を投稿する事ができます。
投稿する際のフローを説明します。
●タイトル
●詳細内容
●学習カテゴリー
●参考資料(URLなど)
アプリ画面は、こちらを参照して下さい。
※ブラウザ側のHTMLは、サーバー側(Flask)で完成させブラウザ側に送られています。
image.png

上図の処理を順次に説明していきます。

【①ブラウザから Request(GET)の発行】
index.htmlの「新規投稿」ボタンを押すと`、Flaskの @app.route("/create_post", ...) という関数が呼び出されます。
@app.route は「デコレータ」と呼ばれ、URLと関数を紐付ける役割を担っており、HTMLから紐付けされる事で移動できます。

index.html(デコレータへの紐付け)
    <a href="/create_post" role="button" class="btn btn-info">新規投稿</a>
app.py(デコレータ宣言部)
@app.route("/create_post", methods=['GET','POST'])
@login_required
def post_study():

【②FlaskでのRequest(要求)の受け取り】
app.py内の関数post_study()のrequest=GET処理により、データベースStudyCategoryに格納されているカテゴリー情報を読み出している。

app.py(GET時の処理)
    # GET時の処理
    elif request.method == 'GET':
        categories = StudyCategory.query.all()
        return render_template('create_post.html', categories=categories)

【③FlaskからのResponse(応答)の発行】
関数post_study()の retun 処理で、create_post.htmlに移動します。
この際に、先ほどデータベースから取り込んだカテゴリー情報も HTML に渡します。

【④ブラウザでの Response(応答)受け取り】
ブラウザがFlaskからのレスポンスを受け取り、create_post.htmlを表示します。
HTMLでは受けっとカテゴリー情報からセレクトボックスを形成します。
以下のように、PythonライブラリーJinja2の機能を用いることで、受け取ったリストデータを、for文を使用して表示をします。
通常のHTMLでは、for文は使用できないので、Jinja2による非常に有効な機能です。

create_post.html(カテゴリーの動的表示)
    {% for category in categories %}
       <option value="{{ category.id }}">{{ category.name }}</option>
    {% endfor %}

【⑤ブラウザから Request(POST)発行】
create_post.htmlの「記録を保存」ボタンを押すと`、Flaskの @app.route("/create_post", ...) という関数が再び呼び出されます。
この際には、HTMLフォームで入力した以下の情報も送られます。
・タイトル
・詳細情報
・カテゴリー、時間
・参考情報

create_post.html(デコレータへの紐付け)
            <form method="POST" action="/create_post">

【⑥FlaskでのRequest(要求)の受け取り】
関数post_study()のrequest=POST処理では、HTMLフォームから受け取ったデータを、以下の3個のテーブルデータに保存します。
タイトル、詳細内容は、データ数は1個になります。
1.カテゴリー、時間
2.参考情報
上記に関しては、受け取る個数は決まっていないので、リストとして受け取り、for文を使用しテーブルデータへの保存処理を実施します。

app.py(POST処理部)
    if request.method == 'POST':
        # 1. 基本データの取得
        title = request.form.get('title')
        content = request.form.get('content')
        
        # タイムゾーン処理
        tokyo_now = datetime.now(ZoneInfo("Asia/Tokyo")).replace(second=0, microsecond=0, tzinfo=None)
        new_post = StudyPost(user_id=current_user.id, title=title, content=content, created_at=tokyo_now)
        db.session.add(new_post)

        # 2. 学習カテゴリー別時間の保存
        categories = request.form.getlist('category_id[]')
        durations = request.form.getlist('duration[]')
        for cat, dur in zip(categories, durations):
            if cat and dur:
                detail = StudyDetail(category_id=int(cat), duration_minutes=int(dur))
                new_post.details.append(detail)

        # 3. 参照データの保存
        ref_titles = request.form.getlist('ref_title')
        ref_urls = request.form.getlist('ref_url')
        ref_ratings = request.form.getlist('ref_rating')
        ref_cat_ids = request.form.getlist('ref_category_id')

        for r_title, r_url, r_rating, r_cat_id in zip(ref_titles, ref_urls, ref_ratings, ref_cat_ids):
            if not r_title:  # タイトルがなければスキップ
                continue
            
            new_ref = Reference(
                title=r_title,
                url=r_url,
                rating=int(r_rating) if r_rating else 3,
                category_id=int(r_cat_id) if r_cat_id else None,
                post=new_post
            )
            db.session.add(new_ref)

        db.session.commit()
        return redirect('/')

【⑦FlaskからのResponse(応答)の発行】
関数post_study()の retun処理では、redirecy('/')となっており、Flaskがブラウザに、「/(ルート)へ移動しなさい!」という指示を出します。
この指示により、ブラウザが次の行動を行います。

【⑧ブラウザから Request(GET)の発行】
ブラウザは、Flaskからの指示のままに行動するので、Flaskの@app.route("/") という関数が呼び出されます。

app.py(デコレータ)
@app.route("/")
@app.route("/index")
def index():
    posts = StudyPost.query.order_by(StudyPost.created_at.desc()).all()
    return render_template("index.html",posts=posts)

【⑨FlaskでのRequest(要求)の受け取り】
関数index()の処理により、データベースStudyPostに格納されている投稿情報を読み出す。

【⑩FlaskからのResponse(応答)の発行】
関数index()の retun処理で、index.html に移動します。
その際に、全投稿の情報も渡しています。

【⑪ブラウザでの Response(応答)受け取り】
ブラウザ側のindex.htmlで、全投稿情報を受取って表示を行います。
ここでもJinja2機能を利用して、投稿の一覧表示を行います。

index.html(投稿記事の一覧表示部)
    {% if posts %}
           {% for post in posts %}
           <div class="card mb-4 shadow-sm">
                   <div class="card-header bg-white d-flex justify-content-between align-items-center">
                           <h5 class="mb-0">
                               <a href="/{{ post.id }}/readmore">
                                   {{ post.title }}
                               </a>
                           </h5>
                           <small class="text-muted">投稿者 : {{ post.author.username }}</small>
                           <small class="text-muted">{{ post.created_at.strftime('%Y/%m/%d %H:%M') }}</small>
                   </div>
                   <div class="card-body">
                           <p class="card-text">{{ post.content | truncate(50, True, '...') }}</p>
                   </div>
           </div>
           {% endfor %}
    {% else %}
        <div class="alert alert-info">まだ学習記録がありません。</div>
    {% endif %}

本実装を通しての気づき

  • Request(要求)/Response(応答)は必ずペア:
    Requset(要求)だけに気を取られやすいですが、必ず発生するResponse(応答)を意識していないと思わぬエラーが発生し対応できなくなります。
  • render_template,redirectの使い分け:
    関数のreturn でrender_templateを使用するとHTMLを完成させ、そのHTML へ移動するが、redirectを使用時には Flask側での処理が発生します。

3. 詳細表示機能

投稿内容の詳細表示を行うページです。
ボタン選択により、投稿内容の更新、削除を行ったり、コミュニケーション機能(いいね、コメント)も使用する事が出来ます。
readmore.htmlを起点としたフローを下図に示します。
※ブラウザ側のHTMLは、サーバー側(Flask)で完成させてブラウザ側に送られています。
image.png

ここでは、「動的ルーティング」機能を使ったエンドポイントの指定を採用しています。
これは、URLの一部を変数(パラメータ)として扱う仕組みで、/1/updateの「1」の部分をIDとして、Flask(app.py)に受渡しています。

ここでの機能は以下の通りとなります。

No 機能 参照
1 削除機能 記事の削除(投稿ユーザー限定)
2 投稿機能 記事の変更ページへ移動(投稿ユーザー限定)
3 いいね機能 ユーザー仲間からの好感を得たらUPします
4 コメント追加 ユーザー仲間からのコメント

JavaScriptを勉強したばかりであり、「いいね機能」をどうしても実装してみたっかったのですが、独力では厳しく、かなりの面でAI頼りになりました。

◆「いいね機能」の特徴
「HTML」,「JavaScript」,「Flask」,「PostgreSQL」が密接に連携した機能です。

  1. 送信は非同期処理で、レスポンス待ちで固まらない
    → 非同期処理なので、通信中もスクロールや他のボタン操作ができる。
  2. ページ全体を読み込まない
    → 変更は「ハートの色」、「数字」だけの置換えなので、動作が非常に高速。

◆「いいね機能」の処理

  1. HTMLで「ボタンをクリック」
    →JavaScriptが全ボタンを監視し、クリックの瞬間を待ち構えています。
  2. JavaScript は、HTMLからpostIdを取得し、POSTリクエストをFlaskに送信
    →HTMLの data-post-id の値を、postIdとして、どの投稿かをFlaskに正確に伝えます。
  3. Flaskで、データテーブル「like」内容を参照し処理を実施
    →「既にいいねがあるか?」を判定し、追加(LIKE)削除(UNLIKE)を判断して、最新の合計数を計算します。
  4. JavaScript は、Flaskから受け取った値で、画面表示を切り替える
    →Flaskからの返値を元に、「色」、「テキスト」を置換えます。
readmore.html(いいね機能)
        <div class="card-footer bg-white border-top-0">
            <button class="btn btn-outline-danger btn-sm like-button" 
                    data-post-id="{{ post.id }}"
                    id="like-btn-{{ post.id }}">
                <i class="bi {% if current_user.id in post.likes|map(attribute='user_id') %}bi-heart-fill{% else %}bi-heart{% endif %}"></i>
                <span class="like-count">{{ post.likes.count() }}</span>
            </button>
        </div>
readmore.js(いいね機能)
document.querySelectorAll('.like-button').forEach(button => {
    button.addEventListener('click', async (e) => {
        const postId = button.dataset.postId;
        const icon = button.querySelector('i');
        const countSpan = button.querySelector('.like-count');

        try {
            const response = await fetch(`/post/${postId}/like`, {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' }
            });
            const data = await response.json();

            if (data.status === 'success') {
                // ハートの形と色を切り替え
                if (data.action === 'liked') {
                    icon.classList.replace('bi-heart', 'bi-heart-fill');
                    icon.classList.replace('text-secondary', 'text-danger');
                } else {
                    icon.classList.replace('bi-heart-fill', 'bi-heart');
                    icon.classList.replace('text-danger', 'text-secondary');
                }
                // カウントの更新
                countSpan.textContent = data.like_count;
            }
        } catch (error) {
            console.error('Error:', error);
        }
    });
});
app.py(いいね機能)
@app.route('/post/<int:post_id>/like', methods=['POST'])
@login_required
def toggle_like(post_id):
    post = StudyPost.query.get_or_404(post_id)
    like = Like.query.filter_by(user_id=current_user.id, post_id=post_id).first()

    if like:
        # 既にいいねしていれば解除
        db.session.delete(like)
        status = 'unliked'
    else:
        # いいねを付与
        new_like = Like(user_id=current_user.id, post_id=post_id)
        db.session.add(new_like)
        status = 'liked'
    
    db.session.commit()
    
    return jsonify({
        'status': 'success',
        'action': status,
        'like_count': post.likes.count()
    })

4. ログイン/ログアウト機能

以下の様なフローになります。
※ブラウザ側のHTMLは、サーバー側(Flask)で完成させてブラウザ側に送られています。
image.png

5. 結果出力機能

データベースに保存したデータを指定した形式で出力する機能となります。
dashboard.htmlを起点としたフローを下図に示します。
※ブラウザ側のHTMLは、サーバー側(Flask)で完成させてブラウザ側に送られています。
image.png

機能 内容
グラフ表示 学習時間の円グラフ、棒グラフでの表示
投稿一覧(ユーザー別) 特定ユーザーの活動履歴を選択表示
参考情報の一覧 登録した参考資料を、評価やカテゴリに絞った選別表示

◆「参考情報の一覧」機能詳細

この機能は、「データの取得(Read)にはGET」、「データの更新(Write)にはPOST」の切分けから、GETでの処理を採用しています。
(※「投稿一覧(ユーザー別)」は、上記の切分けに相違しているので、今後の改善を検討中)

仕組みとしては、以下の様なフローになります。

  1. HTMLフォーム上のセレクトボックス変更時に、onchange="this.form.submit()が働き、Flaskに対して Request(GET) を送ります。
  2. 選択情報(カテゴリー、評価)を?category_id=1&min_rating=3の形でURLに付加した、Requestとなります。
  3. それに基づき、Flask(app.py)側でDBクエリ条件を動的に変更して処理し、HTMLレスポンスを返します。

今回のGET処理は選択情報をURLに付加しますが、POST処理とした場合には、選択情報を「通信の内部(ボディ)」に格納するという違いが生じます。

◆「グラフ表示」フロー

  1. HTMLで「表示ボタン」をクリック
    ・dashboard.html から「ユーザー名」「表示期間」を載せた POSTリクエスト を送信
  2. Flaskの/graphが「Redirect指示」を返す
    ・サーバー側で受け取った値を session に保存
    ・ブラウザに対し「次は /show_dashboard」という Response(302 Redirect)を返す
  3. ブラウザが/show_dashboardへ自動 GETリクエスト
    ・ブラウザは指示通りに GETリクエストを送信
    ・この瞬間、ブラウザの画面はまだ dashboard.html のまま(通信待ちの状態)
  4. Flaskの/show_dashboardが sessionから情報を取得
    ・session に保存していた情報(ユーザー名、期間)を取得。
  5. get_study_statsでDBからデータを抽出
    ・情報を元に、グラフ化に必要な最新の学習データをDBから取得。
  6. FlaskがHTMLを「描画(レンダリング)」してレスポンス
    ・render_template を使い、dashboard_graph.htmlにデータを埋込みレスポンスする。
  7. ブラウザが新しい画面を表示
    dashboard_graph.htmlを受け取り画面が切り替わり、JavaScript(Chart.js等)が動いてグラフが描画される。

◆「グラフ表示」処理

1. 期間に応じた集計単位の切り替え

引数 term に応じて、集計する範囲とグラフのラベル(X軸)の形式を動的に変更します。

  • term == 'year' の場合
    • 範囲: 過去365日分 (timedelta(days=365))
    • 単位: 月ごと (YYYY-MM)
  • それ以外 (month) の場合
    • 範囲: 過去30日分 (timedelta(days=30))
    • 単位: 日ごと (YYYY-MM-DD)

2. 棒グラフ用のデータ抽出・集計 (bar_query)

過去の学習時間を時系列で表示するためのデータを集計します。

  • time_label = func.to_char(...)
    データベースの機能を使い、作成日時(created_at)を指定したフォーマット(fmt)の文字列に変換します。これがグラフのX軸(ラベル)になります。
  • join(StudyDetail)
    StudyPost テーブルと StudyDetail テーブルを結合します。
  • group_by(time_label) / order_by(time_label)
    同一の日付・月ごとにデータをまとめ、時系列順に並べ替えます。

3. 円グラフ用のデータ抽出・集計 (pie_query)

「どのカテゴリにどれだけ時間を費やしたか」割合で表示するためのデータを集計します。

  • テーブルの3枚結合
    StudyCategory(カテゴリ名)から出発し、StudyDetail(学習時間)を経由して、StudyPost(ユーザーID・日付)までを .join() で数珠つなぎに合体させています。
  • group_by(StudyCategory.name)
    カテゴリ名ごとにデータをまとめ、各カテゴリの合計学習時間を算出します。

4. データの整形と返却

最後に、クエリで取得したデータをグラフライブラリ(Chart.jsなど)が扱える形式に変換します。
ここで登場する [r.label for r in bar_query] という書き方は、Pythonの 「リスト内包表記」 と呼ばれるものです。

項目 処理内容
単位変換 bar_values では、分単位のデータを時間に変換(total_minutes / 60)して丸めています。
マッピング クエリ結果(Rowオブジェクト)をリスト形式や辞書形式(_mapping)に展開します。
return {
    "bar_labels": [r.label for r in bar_query],
    "bar_values": [round((r.total_minutes or 0) / 60, 1) for r in bar_query],
}

設計変更と「気づき」

本プロジェクトは当初「Python学習」を主目的としていたため、グラフ出力もすべてPython側で完結させる予定でした。しかし、開発を進める中で以下のメリットを考慮し、「データはPython、描画はJavaScript」という構成へ舵を切りました。

なぜブラウザ側で描画することにしたのか

  • 柔軟性とUXの向上: 静止画ではなく、インタラクティブな操作が可能となる
  • 機能の明確な分離: サーバーは「集計」、ブラウザは「描画」と役割分担でコードの保守性を高める事が出来る
  • モダンなアプリ構成への準拠: Web開発の主流となっている構成に沿った形にする

このような経緯から、フロントエンドの HTML/JS(dashboard_graph)については、AIを活用して効率的に作成し、バックエンドのロジック構築に集中する決断をしました。

技術の選定には「何ができるか」だけでなく「どこで処理させるのが適切か」という視点が重要であることを、今回の開発を通して学ぶことができました。

6. 管理者機能

ここでの機能は下図の例(カテゴリー削除/追加)で示すように、administrator.htmlから Request(POST)を送り、それに沿った処理を実行し、エンドポイント(/administrator)を通って、administrator.htmlにResponseを返す形になります。
※ブラウザ側のHTMLは、サーバー側(Flask)で完成させてブラウザ側に送られています。
image.png

機能一覧は以下の通りです。

デコレータ 機能
create_category カテゴリーの追加
delete_category カテゴリーの削除
create_account 新規ユーザーの登録
delete_account ユーザーの削除
dummy_data_gen 結果表示するためのダミーデータ生成

7. 認証機能

今回のアプリは、管理者権限を持つ人を一人用意して管理する様にしました。
この管理者は、アプリ起動にコード内で設定されたユーザー名、パスワードで自動的に作成されます。
(この管理者でログインした人が、管理者ページで基本設定をする。)
管理者が、ユーザー登録を行い、登録ユーザが学習実績の登録を行えます。
認証機能は、以下の関数により実現されています。

関数 機能
def create_admin アプリ起動時に管理者を作成
admin_required アクセス権限をを管理者のみに制限
load_user ログインユーザーの認識
login 通行証の発行

7. エラー画面のカスタマイズ

システム異常や操作ミスが起きた際、ユーザーを迷わせないための機能です。
Flaskの@app.errorhandler デコレータを使い、エラー発生時にシステムの生々しいエラー画面(白い画面の英文など)を見せるのではなく、アプリ専用のデザイン(error.html)へ安全に誘導するようにしています。

app.py(エラー処理)
@app.errorhandler(404)
def not_found_error(error):
    return render_template('error.html', message='お探しのページは見つかりませんでした。'), 404

@app.errorhandler(500)
def internal_error(error):
    db.session.rollback() 
    return render_template('error.html', message='サーバー内部で予期せぬエラーが発生しました。'), 500

@app.errorhandler(IntegrityError)
def handle_integrity_error(e):
    return render_template('error.html', message='データベースエラーが発生しました。管理者にお問い合わせください。'), 400

本実装に関して:エラーハンドリングの重要性
アプリの最終チェックまで、想定外の入力や操作ミスへの対策が漏れていました。
開発環境では便利な「生々しいエラー画面(スタックトレース)」も、利用者にとっては「何が起きたか分からず不安になる画面」であり、同時にシステムの内部構造を露出させるセキュリティ上のリスクにもなり得ると気づきました。

6.Docker環境の構築

実際にアプリやコードを触って試せる環境を用意できればと思い、Docker環境の構築を試みてアプリ起動までは出来たので、実行方法を説明します。

6-1.Docker環境の説明

各コンテナの内容は、こちらを参照して下さい。
各コンテナの関係性を示します。

image.png

6-2.コマンド実行手順

VScode、PowerShell(Windows) を使用しての流れを説明します。

【①実行ディレクトリへ移動】
GitHub にあるデータの./srcの下に移動して下さい。
ここに、以下のDocker用ファイルが用意されている事を確認して下さい。
compose.yaml
Dockerfile
requirements

【②Dockerコマンド実行】
複数のコンテナを起動するコマンドとなります。

>docker compose up -d --build

【③コンテナ起動確認】

1.「adminer」から「PostgreSQL」内を確認
以下のURL にアクセスし、ログインします。
http://localhost:8080
パスワード:docker

image.png
ログイン後に、以下のテーブルデータが生成されていれば、OKです。

image.png

2.アプリの起動を確認
以下のURL にアクセスし、アプリ画面が立ち上がればOKです。
http://localhost:5000/
image.png

ここまで問題なければ、こちらからアプリを動作できます。
デバッグモードで起動しているので、ソースコード変更がアプリに反映されます。

【④終了コマンド】
コマンド実行でコンテナ、ボリュームを削除されますが、イメージは残ります。
イメージが残る事で、次回のコンテナ起動は早くなります。

>docker compose down --volumes

使用予定が無い場合には、以下のコマンドでイメージも削除します。

>docker system prune -a

6-3.Python抜きでの実行環境

「adminer」,「PostgreSQL」のみDockerで起動し、Python はPC内にあるローカルの物を使用するの事も可能です。(Docker では、「python:3.12」を使用)

【実行方法】
./src/postgres
上記ディレクトリに移り、下記コマンドを実行する。

>docker compose up -d --build

7.まとめ

今回は「掲示板」というもの原型にして、機能追加を行いました。
リレーショナル・データベースは、データテーブルに整理して入力するまでは大変でしたが、ライブラリ機能を利用したデータ出力は非常に便利だと思いました。
今回のグラフ表示は、AI出力のChart.jsを流用したものになりましたが、今後はこの機能部を実装できる技術も学んでいければと思っています。
今回はコード分割対応を全くしておらず、非常に長く見にくいものになりました。
今後はこの対応も身に着けて見やすいコードを心掛けたいと思います。

8.基本操作マニュアル

このアプリのユーザーインターフェースについて簡単な説明を行います。

8-1. トップページ

ログイン後に遷移するページで、投稿の内容の一覧を表示します。

image.png

No 機能 内容
1 新規投稿 新規投稿ページに移動
2 投稿詳細 各投稿内容の詳細表示ページに移動

8-2. 投稿ページ

学習実績を記入してデータベースに保存します。

image.png

No 機能 内容
1 タイトル 学習内容の日時、タイトルなどを記入
2 詳細内容 学習した詳細な内容を記入
3 学習時間 学習カテゴリーと学習時間を記入
4 参考データ 参考にしたURL、カテゴリー、自己評価を記入
5 投稿 記載内容をデータベースに保存

選択されるカテゴリーは、管理者ページで先に作成しておく必要があります。

8-3. 詳細表示ページ

投稿記事の詳細やコミュニケーション機能となります。

image.png

No 機能 内容
1 記事の削除 記事の削除(投稿ユーザー限定)
2 記事の変更 記事の変更ページへ移動(投稿ユーザー限定)
3 いいねボタン ユーザー仲間からの好感を得たらUPします
4 コメント投稿 ユーザー仲間からのコメント

8-4. ログインページ

アプリ・ユーザがログインするページとなります。
このユーザー登録は管理者ページで行います。

image.png

8-5. 結果出力ページ

これまで保存したデータを出力するページとなります。

image.png

No 機能 内容
1 実績表示 学習時間の棒グラフ、円グラフ表示
2 ユーザ別投稿表示 指定ユーザの投稿のみを表示
3 参考資料の一覧 保存資料の一覧、カテゴリー・評価での選別

image.png


image.png

8-6. 管理者ページ

管理者の作業用ページになります。

image.png

No 機能 内容
1 ユーザー管理 ユーザーの追加/削除を実行
2 ユーザー表示 登録されているユーザーを表示
3 カテゴリー管理 カテゴリーの追加/削除を実行
4 カテゴリー表示 登録されているカテゴリーを表示
5 ダミーデータ生成 グラフ表示に必要な投稿を自動作成

9.全コード(GitHubリポジトリ公開中)

作成コードとなります。
ローカル環境で使用したソースとなり、Herokuにデプロイした内容とは少し異なります。

from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from flask_login import UserMixin, LoginManager, login_user, login_required, logout_user, current_user
from werkzeug.security import generate_password_hash, check_password_hash
from zoneinfo import ZoneInfo
from datetime import datetime, timezone, timedelta
from functools import wraps
from flask import Flask, render_template, request, redirect, flash, Response, url_for, jsonify, session, abort
from sqlalchemy import func, extract
from flask_admin import Admin, AdminIndexView, expose
from flask_admin.contrib.sqla import ModelView
from sqlalchemy.exc import IntegrityError
from flask_apscheduler import APScheduler

import os
import logging
import io
import random

##############################################################
# flask アプリのインスタンスを作成
app = Flask(__name__)

### LOG IN 管理システム
login_manager = LoginManager()
login_manager.init_app(app)

####################################################
app.config["SECRET_KEY"] = os.urandom(24)
default_uri = 'postgresql+psycopg://docker:docker@localhost/exampledb'
app.config['SQLALCHEMY_DATABASE_URI'] = os.environ.get('DATABASE_URL', default_uri)

db = SQLAlchemy()
db.init_app(app)

migrate = Migrate(app,db)


#//////////////////////////////////////////////////////////////////////////////////////////
# データベースの作成
#//////////////////////////////////////////////////////////////////////////////////////////

# 1. ユーザー登録
class User(UserMixin, db.Model):
    __tablename__ = 'user'
    id = db.Column(db.Integer, primary_key=True)
    username = db.Column(db.String(50), unique=True, nullable=False)
    password = db.Column(db.String(255), nullable=False)
    is_admin = db.Column(db.Boolean, default=False)
    # リレーションを追加しておくと便利です
    posts = db.relationship('StudyPost', backref='author', lazy=True)

# 学習カテゴリー
class StudyCategory(db.Model):
    __tablename__ = 'study_category'
    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String(50), unique=True, nullable=False)

# 3. 投稿モデル 
class StudyPost(db.Model):
    __tablename__ = 'study_post'
    id = db.Column(db.Integer, primary_key=True)
    user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) # 有効化
    title = db.Column(db.String(200), nullable=False)
    content = db.Column(db.Text, nullable=True)
    created_at = db.Column(db.DateTime(timezone=True), nullable=False, default=datetime.now)
    
    likes = db.relationship('Like', backref='post', lazy='dynamic', cascade="all, delete-orphan")
    comments = db.relationship('Comment', backref='post', lazy='dynamic', cascade="all, delete-orphan")
    details = db.relationship('StudyDetail', backref='post', lazy=True, cascade="all, delete-orphan")
    references = db.relationship('Reference', backref='post', cascade='all, delete-orphan')
    
    
class Like(db.Model):
    __tablename__ = 'likes'
    id = db.Column(db.Integer, primary_key=True)
    user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) 
    post_id = db.Column(db.Integer, db.ForeignKey('study_post.id'), nullable=False)
    created_at = db.Column(db.DateTime, default=datetime.utcnow)


# 4. カテゴリー毎の時間モデル (明細データ)
class StudyDetail(db.Model):
    __tablename__ = 'study_detail'
    id = db.Column(db.Integer, primary_key=True)
    post_id = db.Column(db.Integer, db.ForeignKey('study_post.id'), nullable=False)
    category_id = db.Column(db.Integer, db.ForeignKey('study_category.id'), nullable=False)
    duration_minutes = db.Column(db.Integer, nullable=False)
    # カテゴリー名を簡単に取得するためのリレーション
    category = db.relationship('StudyCategory')


# 参照データテーブル
class Reference(db.Model):
    __tablename__ = 'references'
    id = db.Column(db.Integer, primary_key=True)
    post_id = db.Column(db.Integer, db.ForeignKey('study_post.id'), nullable=False)
    category_id = db.Column(db.Integer, db.ForeignKey('study_category.id'), nullable=True) 
    title = db.Column(db.String(200), nullable=False)
    url = db.Column(db.String(500))
    rating = db.Column(db.Integer) 
    category = db.relationship('StudyCategory', backref='reference_list')

class Comment(db.Model):
    __tablename__ = 'comments'
    id = db.Column(db.Integer, primary_key=True)
    user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
    post_id = db.Column(db.Integer, db.ForeignKey('study_post.id'), nullable=False)
    content = db.Column(db.Text, nullable=False)
    created_at = db.Column(db.DateTime, default=datetime.utcnow)
    # Userモデルとのリレーション(ユーザー名表示用)
    author = db.relationship('User', backref='comments')


##///////////////////////////////////////////////////////////////////////////////////////////////////////
##  ◆ 「dashboard.html」 に関する機能
##///////////////////////////////////////////////////////////////////////////////////////////////////////

########################
# ●参照データの操作 (dashboard.html)
########################
@app.route("/dashboard")
def dashboard():
    categories = StudyCategory.query.all()
    
    selected_category_id = request.args.get('category_id', type=int)
    selected_min_rating = request.args.get('min_rating', type=int) # 新しく取得

    query = Reference.query
    
    # カテゴリーで絞り込み
    if selected_category_id:
        query = query.filter(
            Reference.category.has(id=selected_category_id)
        )
    
    # おすすめ度で絞り込み
    if selected_min_rating is not None:
        # DB上の rating カラムが selected_min_rating 以上であるという条件を追加
        query = query.filter(Reference.rating >= selected_min_rating)

    references = query.all()
   
    return render_template('dashboard.html', 
                           references=references, 
                           categories=categories, 
                           selected_category_id=selected_category_id,
                           selected_min_rating=selected_min_rating)

#########################
## ●データ抽出・集計ロジック(dashboard.html)
#########################
def get_study_stats(user_id, term='month'):
    if term == 'year':
        start_date = datetime.now() - timedelta(days=365)
        fmt = 'YYYY-MM'
    else:
        start_date = datetime.now() - timedelta(days=30)
        fmt = 'YYYY-MM-DD'

    # 1. 棒グラフ用 
    time_label = func.to_char(StudyPost.created_at, fmt)
    
    bar_query = db.session.query(
        time_label.label('label'),
        func.sum(StudyDetail.duration_minutes).label('total_minutes')
    ).join(StudyDetail).filter(
        StudyPost.user_id == user_id,
        StudyPost.created_at >= start_date
    ).group_by(time_label).order_by(time_label).all()

    # 2. 円グラフ用
    pie_query = db.session.query(
        StudyCategory.name.label('label'),
        func.sum(StudyDetail.duration_minutes).label('total_minutes')
    ).join(StudyDetail).join(StudyPost).filter( 
        StudyPost.user_id == user_id,
        StudyPost.created_at >= start_date
    ).group_by(StudyCategory.name).all()

    return {
        "bar_labels": [r.label for r in bar_query],
        "bar_values": [round((r.total_minutes or 0) / 60, 1) for r in bar_query],
        "pie_labels": [r.label for r in pie_query],
        "pie_values": [r.total_minutes or 0 for r in pie_query],
        "raw_data": {
            "bar": [dict(r._mapping) for r in bar_query], 
            "pie": [dict(r._mapping) for r in pie_query]
        }
    }

#########################
## ●グラフ化パラメータ受取り(dashboard.html)
#########################
@app.route("/graph", methods=['POST'])
def handle_graph_post():
    uname = request.form.get('user_name_graph')
    term = request.form.get('disp_term_graph')
    
    session['uname'] = uname
    session['term'] = term

    return redirect('/show_dashboard')

#########################
## ●データ読出し、操作、出力(dashboard.html)
#########################
@app.route("/show_dashboard", methods=['GET'])
def show_dashboard():
    uname = session.get('uname')
    term = session.get('term')

    if not uname or not term:
        return redirect('/index')

    user = User.query.filter_by(username=uname).first()
    if not user:
        return "ユーザーが見つかりません", 404
        
    data = get_study_stats(user.id, term) 

    return render_template('dashboard_graph.html', data=data, uname=uname, term=term)

##########################
# ●指定ユーザー投稿一覧 (dashboard.html)
##########################
@app.route('/post_list/', methods=['GET','POST'])
def post_list():

    if request.method == 'POST':
        uname_plist = request.form.get('user_name_plist')
        session['user_name'] = uname_plist
    else:
        uname_plist = session.get('user_name')
        
    udata_plist = User.query.filter_by(username=uname_plist).first()
    
    if udata_plist:
        posts = StudyPost.query.filter_by(user_id=udata_plist.id).order_by(StudyPost.created_at.desc()).all()
        return render_template('post_list.html', user=udata_plist, posts=posts)
    else:
        return "ユーザーが見つかりません", 404

##///////////////////////////////////////////////////////////////////////////////////////////////////////
##  ◆ 「index.html」 に関する機能
##///////////////////////////////////////////////////////////////////////////////////////////////////////
@app.route("/")
@app.route("/index")
def index():
    posts = StudyPost.query.order_by(StudyPost.created_at.desc()).all()
    return render_template("index.html",posts=posts)

##////////////////////////////////////////////////////////////////////////////////////////////////////////////////
##  ◆ 「create_post.html」 に関する機能
##////////////////////////////////////////////////////////////////////////////////////////////////////////////////
@app.route("/create_post", methods=['GET','POST'])
@login_required
def post_study():
    if request.method == 'POST':
        # 1. 基本データの取得
        title = request.form.get('title')
        content = request.form.get('content')
        
        tokyo_now = datetime.now(ZoneInfo("Asia/Tokyo")).replace(second=0, microsecond=0, tzinfo=None)
        new_post = StudyPost(user_id=current_user.id, title=title, content=content, created_at=tokyo_now)
        db.session.add(new_post)

        # 2. 学習カテゴリー別時間の保存
        categories = request.form.getlist('category_id[]')
        durations = request.form.getlist('duration[]')
        for cat, dur in zip(categories, durations):
            if cat and dur:
                detail = StudyDetail(category_id=int(cat), duration_minutes=int(dur))
                new_post.details.append(detail)

        # 3. 参照データの保存
        ref_titles = request.form.getlist('ref_title')
        ref_urls = request.form.getlist('ref_url')
        ref_ratings = request.form.getlist('ref_rating')
        ref_cat_ids = request.form.getlist('ref_category_id')

        # zipでまとめてループ処理
        for r_title, r_url, r_rating, r_cat_id in zip(ref_titles, ref_urls, ref_ratings, ref_cat_ids):
            if not r_title: 
                continue
            
            new_ref = Reference(
                title=r_title,
                url=r_url,
                rating=int(r_rating) if r_rating else 3,
                category_id=int(r_cat_id) if r_cat_id else None,
                post=new_post 
            )
            db.session.add(new_ref)

        db.session.commit()
        flash("学習記録が正常に保存されました。", "success")
        return redirect('/')
    
    # GET時の処理
    elif request.method == 'GET':
        categories = StudyCategory.query.all()
        return render_template('create_post.html', categories=categories)

##////////////////////////////////////////////////////////////////////////////////////////////////////////////////
##  ◆ 「update.html」 に関する機能
##////////////////////////////////////////////////////////////////////////////////////////////////////////////////
@app.route('/<int:post_id>/update', methods=['GET', 'POST'])
@login_required
def update(post_id):
    post = StudyPost.query.get_or_404(post_id)
    all_categories = StudyCategory.query.all()
    
    if post.user_id != current_user.id:
        flash("編集権限がありません。", "danger")
        return redirect(url_for('index'))

    if request.method == 'POST':
        new_title = request.form.get('title')
        new_content = request.form.get('content')

        if not new_title:
            flash("タイトルは必須項目です。", "warning")
            return render_template('update.html', post=post, all_categories=all_categories)

        # 1. 基本情報の更新
        post.title = new_title
        post.content = new_content
        
        # 2. 学習カテゴリと時間の更新(リストをクリアして再追加)
        post.details.clear() 
        
        category_ids = request.form.getlist('category_id[]')
        durations = request.form.getlist('duration[]')
        for cat_id, dur in zip(category_ids, durations):
            if cat_id and dur:
                detail = StudyDetail(category_id=int(cat_id), duration_minutes=int(dur))
                post.details.append(detail) # post_idは自動で補完されます

        # 3. 参照データの更新
        post.references.clear()
        ref_titles = request.form.getlist('ref_title[]')
        ref_urls = request.form.getlist('ref_url[]')
        ref_ratings = request.form.getlist(f'ref_rating[]')
        ref_category_ids = request.form.getlist('ref_category[]') 
        
        for r_title, r_url, r_rating, r_cat_id in zip(ref_titles, ref_urls, ref_ratings, ref_category_ids):
            if r_title:
                ref = Reference(
                    title=r_title, 
                    url=r_url, 
                    rating=int(r_rating) if r_rating else 3,
                    category_id=int(r_cat_id) if r_cat_id else None
                )
                post.references.append(ref)

        db.session.commit()
        return redirect('/index')

    elif request.method == 'GET':
        return render_template('update.html', post=post, all_categories=all_categories)

##////////////////////////////////////////////////////////////////////////////////////////////////////////////////
##  ◆ 「readmore.html」 に関する機能
##////////////////////////////////////////////////////////////////////////////////////////////////////////////////
@app.route('/<int:post_id>/readmore')
def readmore(post_id):
    post = StudyPost.query.get_or_404(post_id)
    return render_template("readmore.html",post=post)

########################
# ●ポスト削除 (readmore.html)
########################
@app.route('/<int:post_id>/delete', methods=['POST'])
@login_required
def delete_post(post_id):
    post = StudyPost.query.get_or_404(post_id)
    # 投稿者チェック
    if post.user_id != current_user.id:
        abort(403)
    db.session.delete(post)
    db.session.commit()
    return redirect('/index')

########################
# ●コメント機能 (readmore.html)
########################
@app.route('/<int:post_id>/comment', methods=['POST'])
@login_required
def post_comment(post_id):
    post = StudyPost.query.get_or_404(post_id)
    content = request.form.get('content')

    if not content:
        flash('コメント内容を入力してください。', 'danger')
    else:
        new_comment = Comment(
            content=content,
            user_id=current_user.id,
            post_id=post.id
        )
        db.session.add(new_comment)
        db.session.commit()
        flash('コメントを投稿しました。', 'success')
        
    return redirect(f"/{post_id}/readmore")


########################
# ●いいね機能 (readmore.html)
########################
@app.route('/post/<int:post_id>/like', methods=['POST'])
@login_required
def toggle_like(post_id):
    print(f"DEBUG: Post {post_id} にいいねが押されました!") 
    post = StudyPost.query.get_or_404(post_id)
    like = Like.query.filter_by(user_id=current_user.id, post_id=post_id).first()

    if like:
        # 既にいいねしていれば解除
        db.session.delete(like)
        status = 'unliked'
    else:
        # いいねを付与
        new_like = Like(user_id=current_user.id, post_id=post_id)
        db.session.add(new_like)
        status = 'liked'
    
    db.session.commit()
    
    return jsonify({
        'status': 'success',
        'action': status,
        'like_count': post.likes.count()
    })

##////////////////////////////////////////////////////////////////////////////////////////////////////////////////
## ◆ ログイン関連
##////////////////////////////////////////////////////////////////////////////////////////////////////////////////

#####################################
# 起動時に管理者を作成する関数
#####################################
def create_admin():
    admin_username = "admin"
    admin_password = "admin"

    # すでに同名のユーザーがいるか確認
    existing_admin = User.query.filter_by(username=admin_username).first()
    
    if not existing_admin:
        hashed_pw = generate_password_hash(admin_password)
        new_admin = User(
            username=admin_username, 
            password=hashed_pw, 
            is_admin=True
        )
        db.session.add(new_admin)
        db.session.commit()
        print(f"管理者 '{admin_username}' を作成しました。")
    else:
        print("管理者は既に存在します。")

#####################################
# アクセスを管理者のみに制限する機能
#####################################
def admin_required(f):
    @wraps(f)
    @login_required

    def decorated_function(*args, **kwargs):
        # 現在ログインしているユーザーの is_admin 属性を確認する
        if current_user.is_admin:
            return f(*args, **kwargs)
        else:
            flash('管理者権限が必要です。', 'warning')
            return redirect('/login')

    return decorated_function

#####################################
## 現在のユーザを識別する関数
#####################################
@login_manager.user_loader
def load_user(user_id):
    return User.query.get(int(user_id))

#####################################
@app.route("/login", methods=['GET','POST'])
def login():
    if request.method == 'POST':
        username = request.form.get('username')
        password = request.form.get('password')
        user = User.query.filter_by(username=username).first()

        if user and check_password_hash(user.password, password):
            login_user(user)
            return redirect('/index')
        else:
            flash('ユーザー名またはパスワードが違います', 'error')
            return redirect('/login')

    elif request.method == 'GET':
        return render_template('login.html')


##////////////////////////////////////////////////////////////////////////////////////////////////////////////////
##  ◆ 「administrator.html」 に関する機能
##////////////////////////////////////////////////////////////////////////////////////////////////////////////////

########################
# ● 「administrator.html」前の処理
########################
@app.route("/administrator")
@admin_required
def administrator():
    target_user = session.get('last_operated_user', '')
    users = db.session.execute(db.select(User).order_by(User.username)).scalars()
    category_data = StudyCategory.query.all()
    return render_template("administrator.html",
                                      users=users, categories=category_data,
                                      auto_post_status=auto_post_status,
                                      target_user=target_user
                                      )

########################
# ●学習カテゴリ追加 (administrator.html)
########################
@app.route("/create_category", methods=['POST'])
@admin_required
def create_category():
    add_cat_name = request.form.get('category_name')
    existing_category = StudyCategory.query.filter_by(name=add_cat_name).first()

    if existing_category:
        flash('そのカテゴリ名はすでに登録されています。', 'danger')
        return redirect('/administrator')
    else:
        add_cad_data = StudyCategory(name=add_cat_name)
        db.session.add(add_cad_data)
        db.session.commit()
        flash('新しいカテゴリを登録しました。', 'success') 
        return redirect('/administrator')

########################
# ●学習カテゴリ削除 (administrator.html)
########################
@app.route("/delete_category", methods=['POST'])
@admin_required
def delete_category():
    del_cat_id = request.form.get('category_id')
    del_cat_data = StudyCategory.query.get(int(del_cat_id))
    db.session.delete(del_cat_data)
    db.session.commit()
    return redirect('/administrator')


########################
# ●user 追加 (administrator.html)
########################
@app.route("/create_account", methods=['POST'])
@admin_required
def create_account():
    username = request.form.get('add_user_name')
    password = request.form.get('add_user_pass')
    
    # ユーザー名が既に存在するかチェック
    existing_user = User.query.filter_by(username=username).first()
    
    if existing_user:
        # 存在する場合はエラーメッセージを表示してリダイレクト
        flash(f'ユーザー名 "{username}" は既に使用されています。', 'error')
        return redirect('/administrator')
    else:
        # 存在しない場合は新規登録処理を続行
        hashed_pass = generate_password_hash(password)
        user = User(username=username, password=hashed_pass)
        db.session.add(user)
        db.session.commit()
        flash(f'ユーザー "{username}" を登録しました。', 'success')
        return redirect('/administrator')

########################
# ●user 削除 (administrator.html)
########################
@app.route("/delete_account", methods=['POST'])
@admin_required
def delete():
    del_name = request.form.get('del_user_name')
    
    if del_name:
        del_udata = User.query.filter_by(username=del_name).first()
        
        if del_udata:
            db.session.delete(del_udata)
            db.session.commit()
            # サーバーコンソールではなく、ブラウザに成功メッセージを表示
            flash(f'ユーザー "{del_name}" を削除しました。', 'success')
        else:
            # サーバーコンソールではなく、ブラウザにエラーメッセージを表示
            flash(f'ユーザー名 "{del_name}" が見つかりませんでした。', 'error')
            
    return redirect(url_for('administrator'))
    
########################
# ●「Flask-Admin」のカスタマイズ
########################
class CustomAdminIndexView(AdminIndexView):
    @expose('/')
    def index(self):
        total_users = User.query.count()
        total_records = StudyPost.query.count()
        return self.render('admin/custom_index.html', 
                            total_users=total_users,
                            total_records=total_records)

    def is_accessible(self):
        return current_user.is_authenticated and current_user.is_admin
    
    def _handle_view(self, name, **kwargs):
        if not self.is_accessible():
            return redirect(url_for('login', next=request.url))


app.config['FLASK_ADMIN_SWATCH'] = 'cerulean' 
admin = Admin(app, name='<Flask-Admin>', index_view=CustomAdminIndexView(name='Home'))

# 管理画面にモデルを追加
admin.add_view(ModelView(User, db.session))
admin.add_view(ModelView(StudyPost, db.session))
admin.add_view(ModelView(StudyDetail, db.session))
admin.add_view(ModelView(StudyCategory, db.session))
admin.add_view(ModelView(Reference, db.session))
admin.add_view(ModelView(Comment, db.session))

########################
# ●ログアウト機能
########################
@app.route("/logout")
@login_required
def logout():
    logout_user()
    return redirect('/login')

########################
# ●エラー処理
########################
@app.errorhandler(404)
def not_found_error(error):
    return render_template('error.html', message='お探しのページは見つかりませんでした。'), 404

@app.errorhandler(500)
def internal_error(error):
    db.session.rollback() 
    return render_template('error.html', message='サーバー内部で予期せぬエラーが発生しました。'), 500

@app.errorhandler(IntegrityError)
def handle_integrity_error(e):
    return render_template('error.html', message='データベースエラーが発生しました。管理者にお問い合わせください。'), 400

########################
# ●1日1回の自動投稿を行う関数
########################

# スケジューラの初期化
scheduler = APScheduler()

# 実行状態を保持するシンプルなモデル例(既にDBがあれば、設定値を保存するテーブルに追加してください)
# 今回は簡易的にグローバル変数で「実行中フラグ」を管理する例ですが、
# 本来はDBのUserテーブル等に 'is_auto_post_enabled' カラムを作るのが理想です。
auto_post_status = {} # { "username": True/False }

def auto_post_task(app, uname):
    """1日1回実行される実際の処理"""
    with app.app_context():
        udata = User.query.filter_by(username=uname).first()
        if udata:
            # 今日の分のデータを生成
            today = datetime.now()
            categories_demo = [1, 2, 3, 4]
            durations_demo = [random.randint(10, 60) for _ in range(4)]
            
            title_val = f"{today.strftime('%Y-%m-%d')} の自動学習記録"
            cont_val = "自動投稿:今日の学習も順調です!"
            
            new_post = StudyPost(user_id=udata.id, content=cont_val, title=title_val, created_at=today)
            db.session.add(new_post)
            
            for j, (cat, dur) in enumerate(zip(categories_demo, durations_demo), 1):
                detail = StudyDetail(category_id=cat, duration_minutes=dur)
                new_post.details.append(detail)
            
            db.session.commit()
            print(f"AUTO TASK: {uname} の投稿を完了しました")

########################
# ●ダミーデータの生成 (administrator.html)
########################
# スケジュール機能のON/OFFを切り替えるルート
@app.route("/toggle_auto_post", methods=['POST'])
def toggle_auto_post():
    uname = request.form.get('user_name_dummy')
    action = request.form.get('action')
    
    # 最後に操作したユーザー名をセッションに保存する
    if uname:
        session['last_operated_user'] = uname
    
    # ... (ユーザー存在確認などの既存ロジック) ...
    udata = User.query.filter_by(username=uname).first()
    if not udata:
        flash(f"ユーザー {uname} が見つかりません", "error")
        return redirect(url_for('administrator'))
        
    job_id = f"job_{uname}"

    if action == 'start':
        # ... (scheduler.add_job のロジック) ...
        auto_post_status[uname] = True
        flash(f"{uname} の自動投稿を開始しました", "success")
    else:
        # ... (scheduler.remove_job のロジック) ...
        auto_post_status[uname] = False
        flash(f"{uname} の自動投稿を停止しました", "info")
    
    return redirect('/administrator')

########################
# ●ダミーデータの生成 (administrator.html)
########################
@app.route("/dummy_data_gen", methods=['POST'])
def dummy_data_gen():
    # 1. HTMLのフォームから値を取得
    uname = request.form.get('user_name_dummy')  # ユーザー名
    gen_type = request.form.get('gen_data_pat') # 選択された処理の種類
    today = datetime.now()
    categories_demo = [1, 2, 3, 4]

    # 2. ユーザーの存在確認
    udata = User.query.filter_by(username=uname).first()
    if not udata:
        flash(f'ユーザー名 "{uname}" が見つかりませんでした。', 'error')
        return redirect('/administrator')


    if gen_type == 'grp_dat_gen':
        
        for i in range(365):
            target_date = today - timedelta(days=i)
            durations_demo = [random.randint(10, 60) for _ in range(4)] 
            title_val = f"{target_date.strftime('%Y-%m-%d')} の学習記録"
            cont_val = f"今日は{target_date.day}日目の学習です。継続中!"
            
            new_post = StudyPost(user_id=udata.id, content=cont_val, title=title_val, created_at=target_date)
            db.session.add(new_post)
            
            # 子データの作成
            for j, (cat, dur) in enumerate(zip(categories_demo, durations_demo), 1):
                dur_final = int(dur) + i + j + udata.id
                detail = StudyDetail(category_id=cat, duration_minutes=int(dur_final))
                new_post.details.append(detail)
        
        db.session.commit()

    elif gen_type == 'ref_dat_gen':
        for i in range(16):
            target_date = today - timedelta(days=i)
            durations_demo = [random.randint(10, 60) for _ in range(4)] 
            title_val = f"{target_date.strftime('%Y-%m-%d')} の学習記録(参照データ付き)"
            cont_val = f"今日は{target_date.day}日目の学習です。継続中!"
            
            new_post = StudyPost(user_id=udata.id, content=cont_val, title=title_val, created_at=target_date)
            db.session.add(new_post)
            
            for j, (cat, dur) in enumerate(zip(categories_demo, durations_demo), 1):
                dur_final = int(dur) + i + j + udata.id
                detail = StudyDetail(category_id=cat, duration_minutes=int(dur_final))
                new_post.details.append(detail)
            #######################################################
            raw_data = [
                ['【WebAPIやWebデータ自動取得完全攻略】', 'https://www.youtube.com/watch?v=iOXcJoAtXn4', 5, 2],
                ['【Python×FlaskでWebアプリ開発】', 'https://www.youtube.com/watch?v=cxgY9mKDuHw', 5, 2],
                ['【Python副業完全攻略】', 'https://www.youtube.com/watch?v=kV8fpcXo73s', 5, 2],
                ['【Python環境構築完全攻略】', 'https://www.youtube.com/watch?v=BLMc1reLeGc', 5, 1],
                ['【FlaskによるバックエンドAPIの基礎#4】', 'https://www.youtube.com/watch?v=wKZmbMZJQ-s', 3, 1],
                ['【Docker超入門:Windows上にLinux環境を作ろう】', 'https://www.youtube.com/watch?v=iRAy0h5HpZA', 3, 1],
                ['【Docker超入門:コンテナを使ったPython開発環境の構築】', 'https://www.youtube.com/watch?v=CCcF5xuaDtI', 3, 2],
                ['【Docker超入門:コンテナ内でコマンドを実行する2つの方法】', 'https://www.youtube.com/watch?v=PR_JMxvyyfA', 3, 4],
                ['【Dockerのコンテナ型の仮想環境を作ろう!】', 'https://www.youtube.com/watch?v=B5tSZr_QqXw', 3, 4],
                ['【Python入門】プログラミングの基本を2時間半で学ぶ!', 'https://www.youtube.com/watch?v=tCMl1AWfhQQ', 4, 4],
                ['【Pythonでデスクトップアプリ(Excel)を10分で作成!】', 'https://www.youtube.com/watch?v=dPK5xNRUOuI', 4, 3],
                ['【Python】Flaskでつくる5ちゃんねる風掲示板Webアプリ(Part1)', 'https://www.youtube.com/watch?v=DkOZSxaMV8w', 4, 3],
                ['【入門講座】PythonのPandasの使い方について徹底的にまとめていく!', 'https://www.youtube.com/watch?v=sSR2x0y6D9s', 4, 2],
                ['【入門講座】PythonのMatplotlibの使い方について徹底的にまとめていく!', 'https://www.youtube.com/watch?v=6-QCxoA3Rio', 4, 2],
                ['【Python入門】JupyterLab Desktop完全攻略!!【データ分析・機械学習】', 'https://www.youtube.com/watch?v=d_OVFb3gL_8', 4, 1],
                ['Pandas入門 ①読込,抽出【研究で使うPython #53】', 'https://www.youtube.com/watch?v=GoboWIxBBWw', 4, 1],
            ]
            t, u, r, c = raw_data[i]
            new_ref = Reference(
                title=t,
                url=u,
                rating=r,
                category_id=c
            )
            new_post.references.append(new_ref)
            db.session.add(new_ref)
        #######################################################
        db.session.commit()
    return redirect('/administrator')


########################
# ●実行
########################
logging.basicConfig(level=logging.DEBUG) 
#アプリケーションを実行
if __name__ == "__main__":
    with app.app_context():
        db.create_all()  # テーブル作成
        create_admin()   # 管理者作成
    app.run(debug=True, host="0.0.0.0", port=5000)


2
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?