✨ なにを作るの?
この記事では Python + Flask + SQLite を使って、本の登録 & 参照ができるミニWebアプリを作ります。
あわせて GitHub Copilot を“AIペアプロ”っぽく使うコツや、実際に投げたプロンプト例も載せます。
初心者の方でも、「手元で動いた!」まで最短で行けるように解説します。
🧩 できあがる機能
-
/:本の一覧+検索(キーワード/出版年の範囲)+ページネーション -
/add:本の登録(バリデーションあり)→ 登録後に/へリダイレクト -
/detail/<id>:詳細表示(更新・削除ボタンあり) - UI:Bootstrapで見やすく、フラッシュメッセージで成功/失敗を表示
- 運用:
.env対応、ロギング、404/500エラーハンドリング - アプリに合わせたテストコードも作成
- READMEにてアプリの説明も作成してくれる
🧰 前提知識と準備(超かんたん)
- 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ペアプロ風)
-
丸投げしない:「小さな依頼」を順番に投げるのがコツ
例)「登録フォームにバリデーションをつけて」→「一覧をテーブルにして」 - 前提を伝える:「差分最小」「省略禁止」「既存を壊さない」を毎回添える
- 動かしながら修正:起動→試す→“ここ直して”の繰り返しが最速
💬 実際に投げたプロンプト例(コピペ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してフラッシュ表示




