PR
PR

【Python_study_Day21】タスクの追加と作業時間の計測機能を実装

記事内に広告が含まれています。

Pythonの学習21日目です。

前回は、ユーザの追加とログイン機能を作成したので、今回はタスクを追加する機能を実装していきます。

作成するコード

今回作成するプログラムのファイル構成は以下の通りです。

  • Blueprint設定:app/tracker/__init__.py
  • タスク入力フォーム:app/tracker/forms.py
  • ルート処理:app/tracker/routes.py
  • 本体の設定:app/__init__.py(追記)
  • タスク経過時間の計算:app/models.py(追記)
  • HTMLテンプレート:app/tracker/templates/index.html

Blueprintの作成(app/tracker/__init__.py)

タスクに関連する機能の処理を「app/tracker/」にまとめるために、Blueprintの設定を行っていきます。

Blueprintの名前は「tracker」としています。

from flask import Blueprint

# Blueprintの作成
bp = Blueprint('tracker', __name__)

# ルート定義を読み込む
# 循環参照を防ぐためにBlueprintを作成した後にimportするのがポイント
from app.tracker import routes

タスク入力フォームの作成 (app/tracker/forms.py)

タスクを入力するためのフォームになります。

Flask-WTFを使うことで、入力項目の定義とバリデーション(入力のチェック)をまとめて管理しています。

from flask_wtf import FlaskForm
from wtforms import StringField, SubmitField, TextAreaField
from wtforms.validators import DataRequired, Length

class TimeLogForm(FlaskForm):
    task_name = StringField('タスク名', validators=[
        DataRequired(message='タスク名は必須です。'),
        Length(max=140, message='140文字以内で入力してください。')
    ])
    
    category = StringField('カテゴリ', validators=[
        Length(max=64)
    ])
    
    note = TextAreaField('メモ')
    
    submit = SubmitField('開始する')

StringField, SubmitField, TextAreaFieldの使い分け

「from wtforms import StringField, SubmitField, TextAreaField」でインポートしている各フィールドの使い分けです。

  • StringField:1行のテキスト入力
  • TextAreaField:ふくす行のテキスト入力
  • SubmitField:送信ボタン

ルート処理の作成 (app/tracker/routes.py)

タスク関連の処理(一覧表示、開始、停止)を記述します。

from flask import render_template, redirect, url_for, flash, request
from flask_login import login_required, current_user
from datetime import datetime
from app import db
from app.models import TimeLog
from app.tracker import bp
from app.tracker.forms import TimeLogForm

# --- トップページ (タスク一覧 & 開始フォーム) ---
@bp.route('/', methods=['GET', 'POST'])
@login_required  # ← ログインを必須にする
def index():
    form = TimeLogForm()

    # --- タスク開始処理 (POST) ---
    if form.validate_on_submit():
        # 新しいログを作成
        new_log = TimeLog(
            task_name=form.task_name.data,
            category=form.category.data,
            note=form.note.data,
            author=current_user  # 現在ログイン中のユーザーを紐付ける(重要)
            # start_time はモデル側で default=datetime.utcnow になってるから自動で入る
        )
        
        db.session.add(new_log)
        db.session.commit()
        
        flash('新しいタスクを開始しました!')
        return redirect(url_for('tracker.index'))

    # --- タスク一覧表示 (GET) ---
    # ログイン中のユーザーのログだけを取得して、新しい順に並べる
    logs = TimeLog.query.filter_by(user_id=current_user.id).order_by(TimeLog.start_time.desc()).all()

    return render_template('tracker/index.html', title='タイムトラッカー', form=form, logs=logs)


# --- タスク停止処理 (Stop) ---
@bp.route('/<int:id>/stop', methods=['POST'])
@login_required
def stop(id):
    # ログインユーザーのタスクで、指定されたIDのものを取得
    log = TimeLog.query.filter_by(id=id, user_id=current_user.id).first_or_404()
    
    # すでに終了しているタスクを2重に終了させないためのチェック
    if log.end_time:
        flash('このタスクは既に終了しています。')
    else:
        log.end_time = datetime.utcnow()
        db.session.commit()
        flash(f'タスク「{log.task_name}」を終了しました。')

    return redirect(url_for('tracker.index'))

セキュリティ対策(filter_by)

ログを検索する際に「id」だけではなく、「user_id=current_user.id」を追加して検索条件を厳しくしています。

log = TimeLog.query.filter_by(id=id, user_id=current_user.id).first_or_404()
  • id=id:URLで指定されたIDのタスクを探す
  • user_id=current_user.id:現在ログインしているユーザのものであることを確認

「id=id」だけの場合、URLの数字を適当に設定(例: /2/stop を /10/stop)することで、他人のタスクを勝手に終了させてしまうことができてしまいます。(IDOR脆弱性)

もし「id=id」だけの場合、URLの数字を適当に書き換える(例: /2/stop を /10/stop にする)ことで、他人のタスクを勝手に終了させてしまう攻撃(IDOR脆弱性) が可能になってしまいます。

自分のIDも条件に含めることで、他人のデータにはアクセスできないようにしています。

first_or_404()の役割

データの取得結果に応じて、自動で処理を振り分けてくれる便利なメソッドです。

  • first:条件にあう最初のデータを1件だけ取得する
  • _or_404:
    • データが見つかった場合: 変数「log」に格納し処理を続ける
    • 見つからなかった場合:処理を中断し「404 Not Found」のエラー画面を返す

本体の設定 (app/__init__.py)

TrackerのBlueprint設定の追加と、仮のトップページをコメントアウトして無効化しておきます。

import os
from flask import Flask
# extensionsから読み込むのがポイント
from app.extensions import db, migrate, login_manager

def create_app():
    app = Flask(__name__)

    # --- 環境変数から設定を読み込む ---
    # docker-compose.yml で設定した DATABASE_URL がここに入ってくる
    app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY')
    app.config['SQLALCHEMY_DATABASE_URI'] = os.environ.get('DATABASE_URL')
    app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False

    # --- 拡張機能の初期化 ---
    db.init_app(app)
    migrate.init_app(app, db)
    login_manager.init_app(app)
    login_manager.login_view = 'auth.login' # ログイン画面の場所指定

    # --- モデルの読み込み ---
    # これを書かないとFlaskはデータベースのテーブルを作成しない
    from app import models

    # --- Blueprintの登録 ---

    # Auth Blueprint
    # url_prefix='/auth' をつけると、URLが /auth/login や /auth/register になる
    # もし /login にしたい場合は url_prefix を外すか '' にする
    from app.auth import bp as auth_bp
    app.register_blueprint(auth_bp, url_prefix='/auth')

    # Tracker Blueprint
    # url_prefix='/' にすると、トップページがトラッカー画面になる
    from app.tracker import bp as tracker_bp
    app.register_blueprint(tracker_bp, url_prefix='/')

# --- 仮のトップページ ---
#    @app.route('/')
#    def index():
#        from flask_login import current_user
#        if current_user.is_authenticated:
#            return f"こんにちは、{current_user.username}さん!ログイン成功です!"
#        return "ここはトップページです。<a href='/auth/login'>ログインする</a>"

    return app

以前まで使っていた仮のトップページ「@app.route('/')」を生かしたままだと、Tracker Blueprintの設定「app.register_blueprint(tracker_bp, url_prefix='/')
」と表示URLが被ってしまいエラーとなってしまいます。

そのため、仮のトップページ設定をコメントにして設定を無効化しています。(今後使用しないことが確定した削除予定です。)

タスク実行経過時間の取得設定 (app/models.py)

「app/models.py」の「class TimeLog(db.Model):」部分に、pythonの「@property」機能を追加します。

この機能を使用することで、データベースに「duration」というカラムが存在しないのに、プログクラム内では「log.duration」と書くだけで、自動で計算された経過時間を取り出せるようになります。

# --- 2. タイムログモデル (time_logsテーブル) ---
class TimeLog(db.Model):
    __tablename__ = 'time_logs'

    id = db.Column(db.Integer, primary_key=True)
    user_id = db.Column(db.Integer, db.ForeignKey('users.id')) # 外部キー
    
    task_name = db.Column(db.String(140))
    category = db.Column(db.String(64))
    start_time = db.Column(db.DateTime, index=True, default=datetime.utcnow)
    end_time = db.Column(db.DateTime, nullable=True)
    note = db.Column(db.Text, nullable=True)

    # 経過時間を計算するための追加
    @property
    def duration(self):
        # 終了時間がなければ現在時刻を使って経過時間を計算
        end = self.end_time or datetime.utcnow()
        delta = end - self.start_time

        # 時間、分、秒に分解
        hours, remainder = divmod(delta.seconds, 3600)
        minutes, seconds = divmod(remainder, 60)
        return f'{hours}時間{minutes}分'

    def __repr__(self):
        return f'<TimeLog {self.task_name}>'

end = self.end_time or datetime.utcnow()

「or」を使用することで、「self.end_time」が存在しない場合は、「datetime.utcnow」(現在の時間)を使用するという内容です。

これを使用することで、タスクを終了していなくても開始からの経過時間を表示できるようになります。

HTMLテンプレートの作成 (app/tracker/templates/index.html)

index.htmlのテンプレートを作成します。

入力フォームと一覧表示を左右に並べるために、Bootstrapのグリッドシステム(row, col)を使用しています。

{% extends "base.html" %}

{% block title %}Time Tracker{% endblock %}

{% block content %}
<div class="row">
    <div class="col-md-4">
        <h3>新しいタスクを開始</h3>
        <form action="" method="post">
            {{ form.hidden_tag() }}
            <div class="mb-3">
                {{ form.task_name.label(class="form-label") }}
                {{ form.task_name(class="form-control") }}
                {% for error in form.task_name.errors %}
                <div class="text-danger small">{{ error }}</div>
                {% endfor %}
            </div>
            <div class="mb-3">
                {{ form.category.label(class="form-label") }}
                {{ form.category(class="form-control") }}
                {% for error in form.category.errors %}
                <div class="text-danger small">{{ error }}</div>
                {% endfor %}
            </div>
            <div class="mb-3">
                {{ form.note.label(class="form-label") }}
                {{ form.note(class="form-control") }}
                {% for error in form.note.errors %}
                <div class="text-danger small">{{ error }}</div>
                {% endfor %}
            </div>
            {{ form.submit(class="btn btn-primary") }}
        </form>
    </div>

    <div class="col-md-8">
        <h3>タスク履歴</h3>
        {% if logs %}
        <table class="table table-striped">
            <thead>
                <tr>
                    <th>タスク名</th>
                    <th>カテゴリ</th>
                    <th>開始時間</th>
                    <th>終了時間</th>
                    <th>経過時間</th>
                    <th>メモ</th>
                </tr>
            </thead>
            <tbody>
                {% for log in logs %}
                <tr>
                    <td>{{ log.task_name }}</td>
                    <td>{{ log.category }}</td>
                    <td>{{ log.start_time.strftime('%Y-%m-%d %H:%M') }}</td>
                    <td>
                        {% if not log.end_time %}
                        <form action="{{ url_for('tracker.stop', id=log.id) }}" method="post" style="display:inline;">
                            {{ form.csrf_token }}
                            <button type="submit" class="btn btn-sm btn-danger">終了</button>
                        </form>
                        {% else %}
                        <span class="text-muted">{{ log.end_time.strftime('%Y-%m-%d %H:%M') }}</span>
                        {% endif %}
                    </td>
                    <td>
                        <span class="text-muted">{{ log.duration }}</span>
                    </td>
                    <td>{{ log.note }}</td>
                </tr>
                {% endfor %}
            </tbody>
        </table>
        {% else %}
        <p>まだ履歴がありません。</p>
        {% endif %}
    </div>
</div>
{% endblock %}

終了ボタンを表示する、しないの処理

タスクが「作業中」か「終了済み」かによって、表示する内容を切り替える条件分岐 ({% if %}) を入れています。

{% if not log.end_time %}
<form action="{{ url_for('tracker.stop', id=log.id) }}" method="post" style="display:inline;">
   {{ form.csrf_token }}
    <button type="submit" class="btn btn-sm btn-danger">終了</button>
</form>
{% else %}
<span class="text-muted">{{ log.end_time.strftime('%Y-%m-%d %H:%M') }}</span>
{% endif %}
{% if not log.end_time %}

データベースの「end_time」カラムが空っぽ(作業中)かどうかを判定します。

{{ form.csrf_token }}

「routes.py」から渡された「form」変数の中にあるCSRFトークンを利用することで、セキュリティエラーを防いでいます。

動作確認

Webブラウザで「http://localhost:8080」にアクセスします。

まだログインをしていない場合は、画面に「ログインする」とリンクが表示されます。

「ログインする」をクリックしログイン画面に移動し、ユーザ名とパスワードを入力してログインします。

予定では「タスク登録・表示画面」が表示されるはずだったのに、なぜか以前の「ログイン成功の画面」が表示されてしまいました…

※本来であれば、「app/__init__.py」に設定してあった、「仮のトップページ」のコードはコメント化して無効化したはずです。

つまり、この画面が表示されること自体ががおかしい(変更の設定が反映されていない)のですが、この時点では気づいていませんでした…

HTMLファイル(テンプレート)を編集した際は、ブラウザをリロードするだけで即座に反映されていたため、「Pythonファイルも保存するだけで勝手に反映されるだろう」と混同していたのが原因でした。

タスクの登録・表示画面に遷移しない原因の確認

なぜ以前の画面が表示されてしまうのでしょうか?

「ログイン後の転送先(リダイレクト先)の設定が、昔のままになっているのではないか?」と推測しました。

ログイン処理を行っている「app/auth/routes.py」のコードを確認してみます。

# --- ログイン (Login) ---
@bp.route('/login', methods=['GET', 'POST'])
def login():
    # ユーザがログイン済みの場合はトップページにリダイレクトさせる
    if current_user.is_authenticated:
        return redirect(url_for('index'))   ### リダイレクト先がindexとなっている
    
    form = LoginForm()

    # POST送信され、かつバリデーション(入力チェック)に合格した場合のみ実行される
    if form.validate_on_submit():
        user = User.query.filter_by(username=form.username.data).first()
        
        # ユーザーがいない、またはパスワードが違う場合
        if user is None or not check_password_hash(user.password_hash, form.password.data):
            flash('ユーザー名またはパスワードが間違っています。')
            return redirect(url_for('auth.login'))
        
        login_user(user)
        
        next_page = request.args.get('next')
        if not next_page or not next_page.startswith('/'):
            next_page = url_for('index')  ### リダイレクト先がindexとなっている
        return redirect(next_page)

    return render_template('auth/login.html', title='Sign In', form=form)

ログイン後のリダイレクト先URLが「index」となっているのが原因ではないかと推察ました。

return redirect(url_for('index'))

このリダイレクト先の設定を、今回新しく作成した「app/template/tracker/index.html」に変更する必要があるそうです。

別の部分ではリダイレクト先が「auth.login」と設定されているので、多分「tracker.index」といったように設定すれば良いと思うのですが、念のためにGemin先生に正しい設定変更方法を確認してみることにします。

return redirect(url_for('auth.login'))

リダイレクト先URLの設定方法

Gemini先生に聞いた結果、「url_for」の中身は「Blueprint名」 + 「.(ドット)」 + 「関数名」で構成されていることがわかりました。

  • Bluprint名:tracker(「app/tracker/__init__.py」で定義した名前)
  • bp = Blueprint('tracker', __name__)
    
  • 関数名:index(「app/tracker/routes.py」で定義した関数名)
  • return redirect(url_for('tracker.index'))
    

なぜ「Blueprint名.関数名」なのか?

これは、Flaskの 「エンドポイント(Endpoint)」 という仕組みと、「名前空間(Namespacing)」 という考え方で実現されています。

Flaskの内部では、URLと関数を直接つないでいるのではなく、間に「エンドポイント」という ID(識別名) を挟んで管理しています。

  • 通常のアプリ (app.py 直書き); 関数名がそのまま「エンドポイント名」になります。
  • 関数が「def index():」の場合は、エンドポイントは「index」となります。

  • Blueprintを使った場合:Blueprintを作成した時の名前が「苗字」として強制的に付けられます。
  • 関数が「def index():」でBlueprint名が「tracker」の場合は、エンドポイントは「tracker.index」となります。

具体的な仕組み(ドットが付く瞬間)

この「ドット」がどこで生まれるかというと、「app.register_blueprint()」を実行した瞬間になります。

Flaskの内部では以下の処理を行っています。

  1. app.register_blueprint(tracker_bp) が呼ばれる。
  2. Flaskは「tracker_bp」の名前('tracker')を確認する。
  3. そのBlueprintの中にある全関数の名前の前に、「tracker + .」をくっつけて、新しいエンドポイント名として登録する。

つまり、「url_for('tracker.index')」 と書くのは、「関数を指定している」のではなく、「変換後のエンドポイント名(ID)を指定している」 というのが正確な意味になります。

ちなみに、「flask routes」コマンドを実行することで、Flaskが持っているエンドポイントの一覧を確認できます。

$ podman-compose exec app bash
root@0a08b9e0e911:/app# flask routes
Endpoint             Methods    Rule                   
-------------------  ---------  -----------------------
auth.delete_account  POST       /auth/delete_account   
auth.login           GET, POST  /auth/login            
auth.logout          GET        /auth/logout           
auth.register        GET, POST  /auth/register         
static               GET        /static/<path:filename>
tracker.index        GET, POST  /                      
tracker.stop         POST       /<int:id>/stop

HTMLテンプレートの修正

Pythonファイルだけでなく、HTMLテンプレート「app/templates/base.html」のリンク先も同様に、「url_for('index')」部分を全て「url_for('tracker.index')」に変更します。

<!doctype html>
<html lang="ja">

<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">

    <title>{% block title %}{% endblock %} - My Flask App</title>

    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
</head>

<body>
    <nav class="navbar navbar-expand-lg navbar-dark bg-dark mb-4">
        <div class="container">
            <a class="navbar-brand" href="{{ url_for('tracker.index') }}">Flask App</a>

            <div class="collapse navbar-collapse">
                <ul class="navbar-nav ms-auto">
                    <li class="nav-item">
                        <a class="nav-link" href="{{ url_for('tracker.index') }}">ホーム</a>
                    </li>

                    {% if current_user.is_authenticated %}
                    <li class="nav-item">
                        <span class="nav-link disabled">ようこそ、{{ current_user.username }}さん</span>
                    </li>
                    <li class="nav-item">
                        <a class="nav-link" href="{{ url_for('auth.logout') }}">ログアウト</a>
                    </li>
                    {% else %}
                    <li class="nav-item">
                        <a class="nav-link" href="{{ url_for('auth.login') }}">ログイン</a>
                    </li>
                    <li class="nav-item">
                        <a class="nav-link" href="{{ url_for('auth.register') }}">新規登録</a>
                    </li>
                    {% endif %}
                </ul>
            </div>
        </div>
    </nav>

    <div class="container">

        {% with messages = get_flashed_messages() %}
        {% if messages %}
        {% for message in messages %}
        <div class="alert alert-info" role="alert">
            {{ message }}
        </div>
        {% endfor %}
        {% endif %}
        {% endwith %}

        {% block content %}{% endblock %}

    </div>

    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>

</html>

ファイル変更後の動作確認

再度確認を行っても、表示内容は変更されませんでした。

これは、Pythonのコード変更がFlaskサーバーに正しく反映されていなかったことが原因のようです。

通常、デバッグモード「debug=True」のFlaskは、ファイルの変更を検知して自動でリロードしてくれますが、今回は確実を期すためにコンテナを再起動させて設定を読み込ませることにします。

docker-compose.yml があるディレクトリで、以下のコマンドを実行してコンテナを再起動します。

$ podman-compose down
$ podman-compose up -d

※ Pythonファイル(__init__.pyなど)を書き換えた際は、Flaskが自動で再読み込みを行いますが、うまくいかない場合はこのようにコンテナごと再起動すると確実です。

再起動後、ブラウザを更新すると、ようやくタスクの登録画面が表示され、作業時間の記録もできるようになりました!!

作業時間の記録

実際に機能を使ってみます。

タスクの開始

タスク名とカテゴリなどを入力して「開始」ボタンをクリックすることで、タスクの作業時間の記録が始まります。

タスクの終了

作業完了後に「終了」ボタンをクリックすることで、時間の計測を終了することができます。

終了すると、ボタンの代わりに「終了時刻」と「経過時間」が表示されます。

21日目のまとめ

今回は、Blueprintを使ってタスクの登録と終了機能を実装しました。

機能の実装自体も勉強になりましたが、特に以下のトラブルシューティングを通してFlaskの仕組みへの理解が深まりました。

  • BlueprintのURL指定:「url_for('index')」ではなく 「url_for('tracker.index')」のように「Blueprint名.関数名」で指定する必要があること。
  • コンテナの反映:Pythonファイルの変更が反映されない場合は、コンテナの再起動が確実であること。
  • モデルの工夫:「@property」を使うことで、DBにカラムを作らなくても計算結果(経過時間)を扱えること。

エラー画面が出たときは焦りましたが、原因を一つずつ特定していく過程で、ルーティングや名前空間(Namespace)の概念が腹落ちしました。

次回の予定

次回は、以下の機能を追加したいと思います。

  • 時間の表示を日本時間に変更
  • 間違えて登録してしまったタスクを「編集」したり「削除」したりする機能
  • 作成したタスクを選んで作業時間を記録する機能

コメント

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