0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

🧑‍💻 GitHub Copilot で“AIペアプロ”体験:Python + Flask + SQLite で本管理アプリをつくってみよう

Posted at

✨ なにを作るの?

この記事では Python + Flask + SQLite を使って、本の登録 & 参照ができるミニWebアプリを作ります。
あわせて GitHub Copilot を“AIペアプロ”っぽく使うコツや、実際に投げたプロンプト例も載せます。
初心者の方でも、「手元で動いた!」まで最短で行けるように解説します。


🧩 できあがる機能

  • /:本の一覧+検索(キーワード/出版年の範囲)+ページネーション
  • /add:本の登録(バリデーションあり)→ 登録後に / へリダイレクト
  • /detail/<id>:詳細表示(更新・削除ボタンあり)
  • UI:Bootstrapで見やすく、フラッシュメッセージで成功/失敗を表示
  • 運用:.env対応、ロギング、404/500エラーハンドリング
  • アプリに合わせたテストコードも作成
  • READMEにてアプリの説明も作成してくれる

スクリーンショット 2025-08-18 1.12.46.png
スクリーンショット 2025-08-18 1.12.59.png
スクリーンショット 2025-08-18 1.13.21.png
スクリーンショット 2025-08-18 1.13.31.png


🧰 前提知識と準備(超かんたん)

  • Python 3.10+ が入っていること
  • ターミナル/コマンドプロンプトの基本操作
  • VS Code + GitHub Copilot(Chat もあるとベター)

セットアップ:

python -m venv .venv
source .venv/bin/activate    # Windows は .venv\Scripts\activate
pip install -U pip
pip install Flask==3.0.3 Flask-SQLAlchemy==3.1.1 python-dotenv==1.0.1

.env(例):

SECRET_KEY=change-me
DATABASE_URL=sqlite:///books.db

DB初期化 & 起動:

flask --app app.py init-db
flask --app app.py run
# http://127.0.0.1:5000 にアクセス

🗂️ プロジェクト構成(目安)

your-project/
├─ app.py
├─ .env
├─ templates/
│   ├─ base.html
│   ├─ index.html
│   ├─ add.html
│   ├─ detail.html
│   ├─ 404.html
│   └─ 500.html
└─ books.db  # 起動後に生成

🧠 Copilot の使い方のコツ(AIペアプロ風)

  • 丸投げしない:「小さな依頼」を順番に投げるのがコツ
    例)「登録フォームにバリデーションをつけて」→「一覧をテーブルにして」
  • 前提を伝える:「差分最小」「省略禁止」「既存を壊さない」を毎回添える
  • 動かしながら修正:起動→試す→“ここ直して”の繰り返しが最速

スクリーンショット 2025-08-18 1.38.33.png


💬 実際に投げたプロンプト例(コピペOK)

まずは骨組み(最小アプリ)

PythonでFlaskとSQLiteを使って、本の参照と登録ができる最小のWebアプリを作ってください。
条件:
- "/" で本の一覧を表示し、「本を登録する」リンクを置く
- "/add" で登録フォームを表示し、登録後は"/"にリダイレクトする
- モデルはBook(title, author, published_year)
- SQLiteを使う
- ファイルはapp.pyとtemplatesディレクトリに分けて書いてください
- 動く完全なコードを提示してください(省略不可)

UI改善を一気に(Bootstrapや検索UIなど)

前提/方針: 既存機能は壊さず差分最小。コードは省略不可。
依頼: Bootstrap 5 + Icons をCDNで導入し、navbar / alert / table / pagination / 検索フォーム横並び / 詳細カード化 / ダークモード軽対応を templates/*.html に一括適用してください。
変更ファイル名ごとに完全なコードを提示してください。

失敗時の再依頼

提示コードが省略/崩れています。flask run でそのまま起動できる完全コードで、該当テンプレートをすべて省略なしで再提示してください。差分最小、機能は壊さないこと。

🧾 完成コード(app.py 全文)

🔧 テンプレート(templates/*.html)は手元のものをそのまま使ってOK。
下記は ご提供いただいた完成版 を掲載しています。長くてすみません。

from flask import Flask, render_template, request, redirect, url_for, flash
from flask_sqlalchemy import SQLAlchemy
from datetime import datetime
import time
import os
from dotenv import load_dotenv
import logging
import traceback

load_dotenv()  # .envファイルから環境変数を読み込む

# ロギング設定
logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s')

app = Flask(__name__)
app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', 'default-secret')
app.config['SQLALCHEMY_DATABASE_URI'] = os.getenv('DATABASE_URL', 'sqlite:///books.db')
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
db = SQLAlchemy(app)

class Book(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    title = db.Column(db.String(200), nullable=False)  # 最大長変更
    author = db.Column(db.String(120), nullable=False) # 最大長変更
    published_year = db.Column(db.Integer, nullable=True) # 空許容
    created_at = db.Column(db.DateTime, default=datetime.now)  # 登録日時追加

@app.route('/')
def index():
    keyword = request.args.get('keyword', '').strip()
    year_from = request.args.get('year_from', '').strip()
    year_to = request.args.get('year_to', '').strip()
    page = request.args.get('page', 1, type=int)
    per_page = 10
    start_time = time.time()

    query = Book.query

    if keyword:
        query = query.filter(
            (Book.title.ilike(f'%{keyword}%')) |
            (Book.author.ilike(f'%{keyword}%'))
        )
    if year_from and year_from.isdigit():
        query = query.filter(Book.published_year >= int(year_from))
    if year_to and year_to.isdigit():
        query = query.filter(Book.published_year <= int(year_to))

    pagination = query.order_by(Book.created_at.desc()).paginate(page=page, per_page=per_page, error_out=False)
    books = pagination.items
    total_pages = pagination.pages
    search_time = round(time.time() - start_time, 3) if keyword or year_from or year_to else None

    return render_template(
        'index.html',
        books=books,
        keyword=keyword,
        year_from=year_from,
        year_to=year_to,
        search_time=search_time,
        page=page,
        total_pages=total_pages,
        has_prev=pagination.has_prev,
        has_next=pagination.has_next
    )

@app.route('/add', methods=['GET', 'POST'])
def add():
    if request.method == 'POST':
        title = request.form['title'].strip()
        author = request.form['author'].strip()
        published_year_raw = request.form['published_year'].strip()
        errors = []

        # タイトル・著者バリデーション
        if not title:
            errors.append('タイトルは必須です。')
        elif len(title) > 200:
            errors.append('タイトルは200文字以内で入力してください。')
        if not author:
            errors.append('著者は必須です。')
        elif len(author) > 120:
            errors.append('著者は120文字以内で入力してください。')

        # 出版年バリデーション
        published_year = None
        if published_year_raw:
            if not published_year_raw.isdigit():
                errors.append('出版年は整数で入力してください。')
            else:
                year = int(published_year_raw)
                if year < 1000 or year > 2100:
                    errors.append('出版年は1000〜2100の範囲で入力してください。')
                else:
                    published_year = year

        if errors:
            for msg in errors:
                flash(msg, 'error')
            return render_template('add.html', title=title, author=author, published_year=published_year_raw)
        else:
            try:
                new_book = Book(title=title, author=author, published_year=published_year)
                db.session.add(new_book)
                db.session.commit()
                logging.info(f'Book added: {title} by {author}')
                flash('本を登録しました。', 'success')
                return redirect(url_for('index'))
            except Exception as e:
                db.session.rollback()
                logging.error('DB commit error: %s\n%s', e, traceback.format_exc())
                flash('登録に失敗しました。', 'error')
                return render_template('add.html', title=title, author=author, published_year=published_year_raw)
    return render_template('add.html')

# 詳細ページルート追加
@app.route('/detail/<int:book_id>', methods=['GET'])
def detail(book_id):
    book = Book.query.get_or_404(book_id)
    return render_template('detail.html', book=book)

@app.route('/books/<int:book_id>/update', methods=['POST'])
def update_book(book_id):
    book = Book.query.get_or_404(book_id)
    title = request.form['title'].strip()
    author = request.form['author'].strip()
    published_year_raw = request.form['published_year'].strip()
    errors = []

    if not title:
        errors.append('タイトルは必須です。')
    elif len(title) > 200:
        errors.append('タイトルは200文字以内で入力してください。')
    if not author:
        errors.append('著者は必須です。')
    elif len(author) > 120:
        errors.append('著者は120文字以内で入力してください。')

    published_year = None
    if published_year_raw:
        if not published_year_raw.isdigit():
            errors.append('出版年は整数で入力してください。')
        else:
            year = int(published_year_raw)
            if year < 1000 or year > 2100:
                errors.append('出版年は1000〜2100の範囲で入力してください。')
            else:
                published_year = year

    if errors:
        for msg in errors:
            flash(msg, 'error')
        return redirect(url_for('detail', book_id=book.id))
    else:
        try:
            book.title = title
            book.author = author
            book.published_year = published_year
            db.session.commit()
            logging.info(f'Book updated: {title} by {author}')
            flash('本の情報を更新しました。', 'success')
            return redirect(url_for('index'))
        except Exception as e:
            db.session.rollback()
            logging.error('DB update error: %s\n%s', e, traceback.format_exc())
            flash('更新に失敗しました。', 'error')
            return redirect(url_for('detail', book_id=book.id))

@app.route('/books/<int:book_id>/delete', methods=['POST'])
def delete_book(book_id):
    book = Book.query.get_or_404(book_id)
    try:
        db.session.delete(book)
        db.session.commit()
        logging.info(f'Book deleted: {book.title} by {book.author}')
        flash('本を削除しました。', 'success')
    except Exception as e:
        db.session.rollback()
        logging.error('DB delete error: %s\n%s', e, traceback.format_exc())
        flash('削除に失敗しました。', 'error')
    return redirect(url_for('index'))

# エラーハンドリング
@app.errorhandler(404)
def not_found_error(error):
    logging.warning(f'404 Not Found: {request.path}')
    return render_template('404.html'), 404

@app.errorhandler(500)
def internal_error(error):
    logging.error('500 Internal Server Error:\n%s', traceback.format_exc())
    return render_template('500.html'), 500

@app.cli.command("init-db")
def init_db():
    with app.app_context():
        db.create_all()
        print("Initialized the database.")

if __name__ == "__main__":
    with app.app_context():
        db.create_all()
    app.run(debug=True)

🛟 つまずきやすいポイント(Q&A)

Q. ModuleNotFoundError: No module named 'flask' が出た
A. 仮想環境を有効化してから pip install Flask を実行。pip freeze > requirements.txt で依存を固定しておくと◎

Q. Working outside of application context. が出た
A. db.create_all() はアプリケーションコンテキスト内で実行します。
例)with app.app_context(): db.create_all() または flask --app app.py init-db

Q. DBを作り直したい
A. 学習用なら books.db を削除→ init-db を再実行。長期運用は Flask-Migrate を検討。


🧩 仕上げのチェックリスト

  • /add で登録できる
  • / にリダイレクトされて一覧に出る
  • 検索とページネーションが効く
  • 詳細ページから更新・削除できる
  • フラッシュメッセージでエラーがわかる
  • .env で秘密情報を管理している

🏁 まとめ

  • Flask + SQLite でミニアプリを構築
  • GitHub Copilot を“小さな依頼”で積み上げると、AIペアプロのように進められる
  • 初心者でも、動くものを最短で作りながら学べるのが最大のメリット
  • とりあえずやってみること。動かすことが大事
  • エラーになってもAIと相談しながら解消できる。エラーでつまずく時間が短い!

了解しました!では記事の最後に 📎 Appendix として「実際に投げたプロンプト一式」を載せる形にします。Qiita 読者が「再現したい!」と思ったときに参照できる、便利な付録になります 👍


📎 Appendix:実際に投げたプロンプト一式

学習を小さく積み上げるために、以下の順で GitHub Copilot Chat に投げました。コピペでそのまま使えます
ポイントは毎回「差分最小」「省略不可」「既存コードを壊さない」を添えることです。


0) 前提(毎回つけるおまじない)

前提 / 方針:
- 既存コード(app.py, templates/)を壊さず、差分最小で実装して。
- 変更箇所はファイル名つきで示し、必要なら理由も1行で書いて。
- コードは実行可能な完全形で提示(...で省略しない)。
- 日本語のUI文言で。

1) 入力バリデーションの強化

依頼:
登録フォームにバリデーションを追加してください。
- タイトル/著者は必須・前後空白除去・最大長: タイトル200、著者120
- 出版年は空なら許容、整数かつ1000〜2100の範囲
- 失敗時は赤、成功時は緑のフラッシュ
- add()内でシンプルに実装(WTFormsは使わない)
- 変更点をapp.pyとtemplates/index.htmlの差分で示して

2) 一覧のテーブル化+整形

依頼:
一覧表示をテーブル化してください。
- 列: タイトル / 著者 / 出版年 / 登録日時 / 詳細リンク
- CSSは既存のMVP.cssを利用、適度に余白
- 検索キーワードがあるとき、件数と所要時間を上部に表示

3) 検索の拡張(著者・年レンジ)

依頼:
検索機能を拡張してください。
- 部分一致・大文字小文字無視は維持
- 出版年のfrom/to入力をサポート(空は無視)
- /?q=...&year_from=...&year_to=... の形で保持
- app.py と index.html の差分で

4) 詳細ページからの更新/削除

依頼:
詳細ページに編集/削除機能を追加してください。
- detail.htmlに編集フォームと保存ボタン
- POST /books/<id>/update, /books/<id>/delete を app.py に追加
- CSRFは省略でOK
- 成功/失敗時はフラッシュ表示し一覧にリダイレクト

5) ページネーション

依頼:
一覧にページネーションを追加してください。
- 1ページ10件、?page=1 形式
- 検索条件はページ移動時も維持
- 前へ/次へボタンと現在ページ/総ページを表示

6) .env対応

依頼:
環境変数を.envから読み込むようにしてください。
- python-dotenvを導入し、SECRET_KEYとDBパスを.envから
- .env.exampleを追加
- app.py設定読込を修正、requirements.txt更新

7) pytestによる最小E2E

依頼:
pytestで最小限のE2Eテストを書いてください。
- tests/test_app.pyを追加
- Flaskテストクライアントで一時DBを使用
- 登録→一覧→詳細→検索が通る流れ
- pytest.iniを追加
- 実行方法 pytest -q をREADMEに記載

8) ロギングとエラーハンドリング

依頼:
ロギングとエラーハンドリングを強化してください。
- logging標準でINFO出力、エラー時はスタックトレース
- 404/500テンプレートを追加
- DB commit失敗時はrollbackしてフラッシュ表示
0
1
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
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?