
目標
- 商品編集時に以前の商品名や価格をテキストボックスへ表示させる
- アプリケーション内の画像を選択できるようにする
- 商品に「販売中」「完売」「在庫切れ」「販売中止」ステータスを作成して「完売」「在庫切れ」は選択不可とし、「販売中止」は表示しない
商品管理アプリケーションの修正
商品管理アプリケーション修正
ここからは、これまで、商品管理アプリケーションで不便と感じていた部分について修正を行います。具体的には次の通りです。
- 商品一覧の編集ボタンをクリックしたときに、表示される編集画面で、編集前の商品名、価格、カテゴリーを初期値でセットさせます。
(Git では release-products-001 ブランチで修正) - 商品編集で画像をアップロードする以外に、既にアップロード済みの画像からも画像を選択できるようにします。
(Git では release-products-002 ブランチで修正) - 商品に「販売中」「完売」「在庫切れ」「販売中止」ステータスを作成して「完売」「在庫切れ」は選択不可とし、「販売中止」は表示しないようにします。
(Git では release-products-003 ブランチで修正)
release-productsブランチの作成
release-productsブランチの作成
商品管理アプリケーションは既に基本となる構成については作成を完了しています。このように、機能の追加漏れがあるような場合は、再度、featureブランチを作成するよりも、releaseブランチとしてブランチを作成して作業を行うのが一般的です。
Git Bushを立ち上げるとプロンプトの右側に(develop)と表示され、現在のブランチがdevelopブランチであることが分かります。(次の表示はVisual Studio CodeのGit Bushのプロンプトです。)
User@dellDevice MINGW64 ~/Desktop/FlaskProj (develop)
次のようにコマンドを入力していきます。次のように表示されるはずです。
$ git status

$ git checkout -b release-products-003

$ git status

商品管理アプリケーションの修正(release-products-003)
app.pyの編集(productsapp内)
app.pyファイルの「edit_product関数」を次のように編集します。
# ルート: 商品を編集
@products_bp.route("/product/edit/<int:product_id>", methods=["GET", "POST"])
def edit_product(product_id):
product = Product.query.get_or_404(product_id)
form = AddProductForm(obj=product)
# 編集の場合、該当するカテゴリーをデフォルトで選択
form.category.data = product.category_id
form.set_category_choices()
if form.validate_on_submit():
form.process(request.form)
product.name = form.product_name.data
product.price = form.product_price.data
product.category_id = form.category.data
product.status = form.status.data
# 画像のアップロード処理
if "product_image" in request.files:
image = request.files["product_image"]
if image and allowed_file(image.filename):
filename = secure_filename(image.filename)
image_path = os.path.join(current_app.config["UPLOAD_FOLDER"], filename)
# もしすでに画像があれば削除
if product.image_url and product.image_url != "images/noimage.png":
old_image_path = os.path.join(
current_app.config["UPLOAD_FOLDER"],
os.path.basename(product.image_url),
)
if os.path.exists(old_image_path):
os.remove(old_image_path)
# 新しい画像を保存
image.save(image_path)
product.image_url = f"images/{filename}" # 新しい画像URLを保存
# 既存画像の選択
elif request.form.get("selected_image"):
product.image_url = request.form["selected_image"]
db.session.commit()
flash("商品情報を更新しました", "success")
return redirect(url_for("products.product_list"))
return render_template(
"product_form.html", form=form, title="商品編集", product=product
)
form.process(request.form)を利用することでカテゴリーとステータスの更新を行えるようにしています。この記述がないと更新がうまく働きません。
models.pyの編集(productsapp内)
models.pyファイルのProductモデルを次のように編集します。
# 商品モデル(テーブル)の定義
class Product(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), nullable=False)
price = db.Column(db.Integer, nullable=False)
category_id = db.Column(
db.Integer, db.ForeignKey("category.id"), nullable=True
) # カテゴリID
image_url = db.Column(db.String(255), nullable=True) # 画像のURLを保存するカラム
status = db.Column(
db.String(20), nullable=False, default="販売中"
) # 商品ステータス
def __repr__(self):
return f"<Product {self.id} - {self.name} - {self.price}>"
forms.pyの編集(productsapp内)
forms.pyファイルのAddProductFormクラスを次のように編集します。
class AddProductForm(FlaskForm):
product_name = StringField("商品名", validators=[DataRequired()])
product_price = IntegerField(
"価格",
validators=[
DataRequired(),
NumberRange(min=1, message="価格は1以上である必要があります"),
],
)
category = SelectField("カテゴリー", coerce=int) # カテゴリー選択フィールド
product_image = FileField(
"商品画像",
validators=[
FileAllowed(
["jpg", "png", "jpeg", "gif"], "画像ファイルのみ許可されています"
)
],
)
status = SelectField(
"Status",
choices=[
("available", "販売中"),
("sold_out", "完売"),
("out_of_stock", "在庫切れ"),
("discontinued", "販売終了"),
],
default="available",
)
def set_category_choices(self):
self.category.choices = [
(c.id, c.name) for c in Category.query.all()
] # DBから取得
product_form.htmlの編集(productsapp/templates内)
product_form.html を次のように編集します。
{% extends "base.html" %}
{% block title %}{{ title }}{% endblock %}
{% block header %}
<ul>
<li><a href="{{ url_for('products.manage_products') }}" class="button-link">商品管理ページ</a></li>
<li><a href="{{ url_for('products.product_list') }}" class="button-link">商品一覧に戻る</a></li>
</ul>
{% endblock %}
{% block content %}
<h1>{{ title }}</h1>
<form method="POST"
action="{{ url_for('products.edit_product', product_id=product.id) if product else url_for('products.add_product') }}"
enctype="multipart/form-data">
{{ form.hidden_tag() }} <!-- CSRFトークンを含めるため -->
<label for="product_name">商品名</label>
{{ form.product_name(value=product.name if product else '') }} <!-- 商品名入力フィールド -->
{% if form.product_name.errors %}
<ul>
{% for error in form.product_name.errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
{% endif %}
<label for="product_price">価格</label>
{{ form.product_price(value=product.price if product else '') }} <!-- 価格入力フィールド -->
{% if form.product_price.errors %}
<ul>
{% for error in form.product_price.errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
{% endif %}
<label for="category">カテゴリー</label>
{{ form.category() }}
{% if form.category.errors %}
<ul>
{% for error in form.category.errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
{% endif %}
<label for="product_status">ステータス</label>
{{ form.status() }}
{% if form.status.errors %}
<ul>
{% for error in form.status.errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
{% endif %}
<label for="product_image">商品画像</label>
<!-- 既存の画像がある場合は表示 -->
{% if product and product.image_url %}
<div>
<img id="preview-image" src="{{ url_for('products.static', filename=product.image_url) }}" alt="現在の画像"
style="width: 100px; height: 100px;">
<p>※新しい画像を選択すると上書きされます</p>
<p id="upload-file-name"></p>
</div>
{% else %}
<img id="preview-image" src="" alt="プレビュー" style="display: none; width: 100px; height: 100px;">
{% endif %}
<label for="product_image" class="file-upload-label">画像のアップロード</label>
<input type="file" id="product_image" name="product_image" accept="image/*">
<!-- 画像選択ボタン -->
<label for="selected-image" id="select-image-btn">アプリ内画像から選択</label>
<input type="hidden" id="selected-image" name="selected_image" value="{{ product.image_url if product else '' }}">
<!-- 画像選択モーダル(初期状態は非表示) -->
<!-- 画像選択モーダル -->
<div id="image-modal" class="modal" style="display: none;">
<div class="modal-content">
<span id="close-modal" class="close">×</span>
<h3>画像を選択</h3>
<div id="image-list"></div>
</div>
</div>
<input type="submit" value="商品を登録する">
</form>
<script>
document.getElementById("product_image").addEventListener("change", function () {
const file = this.files[0];
const fileNameDisplay = document.getElementById("upload-file-name");
if (file) {
fileNameDisplay.textContent = file.name;
// 画像プレビューを表示
const reader = new FileReader();
reader.onload = function (e) {
const preview = document.getElementById("preview-image");
preview.src = e.target.result;
preview.style.display = "block";
};
reader.readAsDataURL(file);
}
});
document.addEventListener("DOMContentLoaded", function () {
fetch("{{ url_for('products.list_product_images') }}")
.then(response => response.json())
.then(data => {
const imageList = document.getElementById("image-list");
imageList.innerHTML = "";
data.images.forEach(image => {
const imgElement = document.createElement("img");
imgElement.src = "{{ url_for('products.static', filename='images/') }}" + image;
imgElement.classList.add("selectable-image");
imgElement.style.width = "100px";
imgElement.style.height = "100px";
imgElement.style.cursor = "pointer";
imgElement.dataset.image = "images/" + image;
imgElement.addEventListener("click", function () {
document.getElementById("selected-image").value = this.dataset.image;
document.getElementById("preview-image").src = this.src;
document.getElementById("preview-image").style.display = "block";
document.getElementById("image-modal").style.display = "none";
});
imageList.appendChild(imgElement);
});
})
.catch(error => console.error("画像取得エラー:", error)); // エラーハンドリング
});
document.getElementById("select-image-btn").addEventListener("click", function () {
document.getElementById("image-modal").style.display = "block";
});
document.getElementById("close-modal").addEventListener("click", function () {
document.getElementById("image-modal").style.display = "none";
});
</script>
{% endblock %}
order_menu.htmlの編集(productsapp/templates内)
order_menu.html を次のように編集します。
{% extends 'order_base.html' %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
<h2>メニュー</h2>
<!-- カテゴリータブ -->
<div class="tab-container">
<div class="tab active" data-category="all">すべて</div>
{% for category in categories %}
<div class="tab" data-category="{{ category.id }}">{{ category.name }}</div>
{% endfor %}
</div>
<div class="content-container">
<!-- メニューリスト -->
<div class="menu">
{% for product in products %}
{% if product.status != 'discontinued' %}
<div class="menu-item" data-category="{{ product.category_id }}" {% if product.status=='sold_out' or product.status=='out_of_stock' or
table.bill.status=="pending" %} style="pointer-events: none; opacity: 0.5;" {% endif %}>
<a href="{{ url_for('orders.order_form', table_id=table.id, product_id=product.id) }}" target="_self">
<img src="{{ url_for('products.static', filename=product.image_url) }}" alt="{{ product.name }}">
<p>{{ product.name }}</p>
<p>¥{{ product.price }}</p>
{% if product.status == 'sold_out' %}
<span class="status-label sold-out-label">完売</span>
{% elif product.status == 'out_of_stock' %}
<span class="status-label out-of-stock-label">在庫切れ</span>
{% endif %}
</a>
</div>
{% endif %}
{% endfor %}
</div>
<!-- 注文リスト(右側に配置) -->
<div class="order-summary">
<!-- 合計金額 -->
<div class="total-amount-container">
<p><strong>合計金額: ¥{{ total_amount | int }}</strong></p>
</div>
<div class="cart-check-container">
<!-- カート確認ページへのリンク -->
<a href="{{ url_for('orders.order_cart', table_id=table.id) }}">カートを確認</a>
</div>
<!-- 現在の注文リスト -->
<div class="order-list-container">
<h3>現在の注文リスト</h3>
<ul class="order-list">
{% for order in orders %}
<li>{{ order.product_name }} x{{ order.quantity }} (¥{{ order.total | int }})</li>
{% else %}
<li>現在、注文はありません。</li>
{% endfor %}
</ul>
</div>
<!-- ステータス変更ボタン -->
<div class="status-change-container">
{% if table.bill.status == "Order in progress" %}
<form action="{{ url_for('orders.change_status', table_id=table.id, status='pending') }}" method="post">
<button type="submit" class="status-button">会計予定に変更</button>
</form>
{% elif table.bill.status == "pending" %}
<form action="{{ url_for('orders.change_status', table_id=table.id, status='Order in progress') }}"
method="post">
<button type="submit" class="status-button">注文可能に戻す</button>
</form>
{% endif %}
</div>
</div>
</div>
<!-- カテゴリータブの切り替えスクリプト -->
<script>
document.addEventListener("DOMContentLoaded", function () {
const tabs = document.querySelectorAll(".tab");
const items = document.querySelectorAll(".menu-item");
tabs.forEach(tab => {
tab.addEventListener("click", function () {
const category = this.getAttribute("data-category");
// タブのアクティブ状態を更新
tabs.forEach(t => t.classList.remove("active"));
this.classList.add("active");
// メニューアイテムの表示切り替え
items.forEach(item => {
if (category === "all" || item.getAttribute("data-category") === category) {
item.style.display = "block";
} else {
item.style.display = "none";
}
});
});
});
});
</script>
{% endblock %}
style.cssの編集(ordersapp/static/css内)
style.css を次のように編集します。
/* 全体のスタイル */
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
/* 水平方向の中央揃え */
align-items: center;
background-color: #f4f4f9;
/* ビューポート全体を使う */
height: 100vh;
justify-content: flex-start;
}
main {
justify-content: center;
/* 垂直方向の中央揃え */
padding-bottom: 100px;
}
/* メインフォーム */
form {
padding: 20px;
max-width: 600px;
width: 100%;
margin: 0 auto;
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
}
/* ヘッダー */
h2 {
text-align: center;
font-size: 2rem;
color: #333;
margin: 50px 0 20px;
}
h3 {
text-align: center;
font-size: 1.3rem;
color: #333;
margin: 50px 0 20px;
}
a {
text-decoration: none;
color: inherit;
}
/* メインコンテナ */
.content-container {
display: flex;
justify-content: space-between;
align-items: flex-start;
max-width: 1200px;
width: 100%;
padding: 20px;
gap: 20px;
}
/* メニューエリア */
.menu {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
width: 80%;
margin: 10px;
}
.menu-item {
position: relative;
text-align: center;
padding: 0px;
border: 1px solid #ddd;
background-color: #ffffff;
box-shadow: 0px 4px 6px rgba(0, 0, 0, 0.1);
transition: transform 0.3s, box-shadow 0.3s;
}
.menu-item:hover {
transform: scale(1.02);
box-shadow: 0px 6px 12px rgba(0, 0, 0, 0.2);
}
.menu-item img {
width: 100%;
height: auto;
border-radius: 0;
}
/* カテゴリータブ */
.tab-container {
display: flex;
justify-content: center;
margin-bottom: 20px;
}
.tab {
padding: 10px 20px;
margin: 0 5px;
cursor: pointer;
background-color: #e1e9ef;
color: black;
transition: background-color 0.3s;
}
.tab:hover {
background-color: #ff9558;
color: white;
}
.tab.active {
background-color: #ff9558;
color: white;
}
/* 注文リストエリア(右側) */
.order-summary {
width: 25%;
position: sticky;
top: 10px;
background-color: #ffffff;
padding: 15px;
box-shadow: 0px 4px 6px rgba(0, 0, 0, 0.1);
}
/* 合計金額のスタイル */
.total-amount-container {
text-align: center;
font-size: 1.3rem;
font-weight: bold;
margin-bottom: 10px;
padding: 10px;
background-color: #fffbcc;
border: 1px solid #ddd;
}
/* カート確認のスタイル */
.cart-check-container {
text-align: center;
font-size: 1.3rem;
font-weight: bold;
margin-bottom: 10px;
padding: 10px;
background-color: #ffd6cc;
border: 1px solid #ddd;
}
/* 注文リストのコンテナ */
.order-list-container {
max-height: 400px;
overflow-y: auto;
padding: 10px;
background-color: #fff;
border: 1px solid #ddd;
}
.order-list-container h3 {
text-align: center;
margin-top: 0;
}
.order-list {
list-style: none;
padding: 0;
margin: 0;
}
.order-list li {
padding: 5px;
border-bottom: 1px solid #ddd;
}
/* スクロールバーのカスタマイズ */
.order-list-container::-webkit-scrollbar {
width: 8px;
}
.order-list-container::-webkit-scrollbar-thumb {
background: #aaa;
}
/* 商品の情報 */
.product-info {
display: flex;
align-items: center;
gap: 20px;
border-bottom: 1px solid #ddd;
padding-bottom: 20px;
width: 100%;
justify-content: center;
}
/* 商品画像 */
.product-info img {
width: 150px;
height: 150px;
object-fit: cover;
border-radius: 8px;
box-shadow: 0px 4px 6px rgba(0, 0, 0, 0.1);
}
/* 商品名と価格 */
.product-info p {
font-size: 1.1rem;
color: #555;
}
/* 数量選択 */
label {
font-size: 1.1rem;
color: #333;
}
/* 数量フォーム */
input[name="quantity"] {
width: 60px;
padding: 8px;
font-size: 1rem;
border: 1px solid #ddd;
border-radius: 0;
text-align: center;
margin: 0 auto;
}
/* 数量入力フォームのスタイル */
.quantity-form,
.remove-form {
display: inline-block;
align-items: center;
gap: 10px;
}
.quantity-form input {
width: 60px;
padding: 8px;
font-size: 1rem;
border: 1px solid #ddd;
border-radius: 0;
text-align: center;
display: flex;
justify-content: center;
}
.quantity-form button,
.remove-form button {
padding: 8px 15px;
font-size: 1rem;
background-color: #ff9558;
color: white;
border: 1px solid #ddd;
cursor: pointer;
transition: background-color 0.3s ease;
}
.quantity-form button:hover,
.remove-form button:hover {
background-color: #e77f44;
}
/* ボタン */
button[type="submit"] {
background-color: #ff9558;
color: white;
padding: 12px;
font-size: 1.2rem;
border: none;
cursor: pointer;
transition: background-color 0.3s ease;
width: 100%;
margin-top: 20px;
}
button[type="submit"]:hover {
background-color: #e77f44;
}
/* 入力フィールドのスタイル */
input,
select,
button {
padding: 10px;
cursor: pointer;
font-size: 1rem;
border: 1px solid #ddd;
border-radius: 0;
}
/* カート全体のコンテナ */
.cart-container {
width: 100%;
margin: 0 auto;
padding: 0;
}
.cart-info {
display: flex;
align-items: center;
gap: 15px;
margin: 10px;
flex-grow: 1;
}
/* カートの画像 */
.cart-info img {
width: 100px;
height: 100px;
object-fit: cover;
border-radius: 8px;
box-shadow: 0px 4px 6px rgba(0, 0, 0, 0.1);
}
.cart-info span {
font-size: 1rem;
color: #555;
/* 文字を折り返さないようにする */
white-space: nowrap;
}
/* カートのアイテムリスト */
.cart-list {
list-style-type: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: row;
align-items: center;
border-bottom: 1px solid #ddd;
padding: 10px 0;
}
/* カートの各アイテム */
.cart-list li {
padding: 10px 0;
border-bottom: 1px solid #ddd;
font-size: 18px;
}
/* 合計金額の表示 */
.total-amount {
font-size: 1.5rem;
font-weight: bold;
padding: 15px;
width: 100%;
margin-bottom: 20px;
text-align: center;
}
/* キャンセルボタン */
.cancel-button {
color: red;
padding: 10px 20px;
cursor: pointer;
text-align: center;
border-radius: 0;
border: 1px solid red;
background-color: white;
}
/* 横並びのボタン */
.button-container {
display: flex;
justify-content: flex-end;
gap: 10px;
align-items: center;
width: 286px;
margin: 10px;
}
/* ステータス変更コンテナ */
.status-change-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
margin-top: 30px;
width: 100%;
padding: 0;
background-color: #ffffff;
box-shadow: 0px 4px 6px rgba(0, 0, 0, 0.1);
border-radius: 0;
}
.status-change-container form {
margin: 0;
padding: 0;
}
/* ステータス変更ボタン */
.status-button {
background-color: #ff9558;
color: white;
font-size: 1.2rem;
border: none;
cursor: pointer;
transition: background-color 0.3s ease;
width: 100%;
max-width: 300px;
/* ボタンの最大幅 */
text-align: center;
margin-top: 20px;
}
.status-button:hover {
background-color: #e77f44;
}
/* 会計予定のテキスト */
.status-change-container p {
font-size: 1.2rem;
color: #ff9558;
font-weight: bold;
margin-top: 10px;
}
/* フッター(オプション) */
footer {
background-color: #333;
color: white;
text-align: center;
padding: 10px;
width: 100%;
position: fixed;
bottom: 0;
}
.status-label {
position: absolute;
top: 10px;
right: 10px;
padding: 5px 10px;
font-size: 14px;
font-weight: bold;
color: #fff;
border-radius: 5px;
}
.sold-out {
background-color: #f8d7da;
color: #721c24;
pointer-events: none;
opacity: 0.7;
}
.sold-out-label {
background-color: #dc3545;
}
.out-of-stock {
background-color: #fff3cd;
color: #856404;
pointer-events: none;
opacity: 0.7;
}
.out-of-stock-label {
background-color: #ffc107;
}
「dump.sql」ファイルの名前を「old_dump.sql」に変更します。

古いデータベースを削除してアプリケーションを再起動します。

http://127.0.0.1:5000/products/manage_productsにアクセスし、再度、カテゴリーと商品を登録します。
商品登録時にステータスの登録が追加されているます。商品のうち「ビール」に「完売」、「ハイボール」に「在庫切れ」、「りんごジュース」に「販売終了」を設定します。



商品一覧の見た目は、これまで通りです。

http://127.0.0.1:5000/orders/order/table/1にアクセスします。
「完売」の「ビール」と、「在庫切れ」の「ハイボール」がグレーアウトして選択できなくなります。「販売終了」の「りんごジュース」は非表示となります。

コミット作業とrelease-productsブランチの削除
作業が完了したので「release-products-003」ブランチを「develop」ブランチにマジして、「release-products-003」ブランチは削除していきます。
git statusコマンドでファイルの状態を確認します。

git add .コマンドを入力します。

再度、git statusコマンドを入力します。

git commitコマンドを入力します。

Fix:商品管理アプリケーションの修正-003(20250324)
今回は設計書などはないため、設計書番号などの提示はなし。Productテーブルに「販売中」「完売」「在庫切れ」「販売中止」を登録できるステータスを追加して、商品メニューの表示を行うテンプレートで「完売」「在庫切れ」は選択不可とし、「販売中止」は表示しないように修正。

上書き保存をして「COMMIT_EDITMSG」ファイルを閉じるとコミットが完了します。

developブランチへのマージ
コミットしたrelease-products-003ブランチをdevelopブランチへマージします。
git checkout developコマンドで、ブランチをdevelopブランチへ切り替えます。

git merge release-products-003コマンドで release-products-003ブランチの内容をdevelopブランチへマージします。

release-products-003ブランチの削除
developブランチへのマージが完了したら、release-products-003ブランチを削除します。
git branch -d release-products-003コマンドでrelease-products-003ブランチを削除します。

今回は以上になります。

ブックマークのすすめ
「ほわほわぶろぐ」を常に検索するのが面倒だという方はブックマークをお勧めします。ブックマークの設定は別記事にて掲載しています。

