🚀 Day 9:使用者互動與表單提交(Form & POST)
5 天營隊第二週 (Day 9 / 總時數 6 Hours) | 告別靜態網頁!掌握 HTML 表單送出、CSRF 防範與資料庫動態 CRUD 互動!
📖 為什麼學 these?——「理財咖啡館開啟顧客點餐與撤單互動」關卡冒險故事線
過去兩天我們只能在網頁上「看」資料庫的明細。今天我們要讓使用者可以手動在網頁上輸入「品項、金額、分類」並按下【新增】!我們要學習 **HTTP POST 表單提交**、蓋上 **CSRF 防偽安全印章**、在網頁表格旁放上 **一鍵刪除垃圾桶**,並在新增成功後彈出綠色的提醒框,讓你的記帳網站達到真正商業級的互動流!
| 單元名稱 | 記帳程式關卡角色 | 為什麼這個單元不可或缺?(技術關聯) |
|---|---|---|
| 單元 1:HTTP GET vs POST | 📝 觀看菜單 vs 遞交點餐單 (<form>) | 理解 GET(取得資料)與 POST(傳送機密修改)的差異,撰寫 HTML <form action="" method="POST">,對應 Day 1 的 input()。 |
| 單元 2:CSRF 防護與接收資料 | 🛡️ 防偽安全印章 ({% csrf_token %}) | 在表單加上 {% csrf_token %} 防止駭客跨站偽造點餐,並在 View 使用 request.POST.get() 擷取輸入內容。 |
| 單元 3:實作「新增消費」功能 | 🍳 寫入廚房帳本與自動回到首頁 | 將接收到的 POST 資料透過 ORM 寫入 SQLite(Expense.objects.create()),並用 redirect('home') 轉向首頁,對應 Day 5 的 handle_add()。 |
| 單元 4:動態 URL 與刪除功能 | 🗑️ 撤銷點餐垃圾桶按鈕 (<int:id>) | 設定動態 URL 參數(如 delete/<int:pk>/),在網頁表格旁實作垃圾桶按鈕,點擊立刻移除該筆資料,對應 Day 5 的 handle_delete()。 |
| 單元 5:URL 查詢參數與篩選 | 🔎 分類快速檢索條 (?category=餐飲) | 實作帶有 GET 查詢參數的下拉選單,結合 Django ORM .filter() 實現按分類即時篩選,對應 Day 5 的 handle_filter()。 |
| 單元 6:表單防呆與 Flash 訊息 | 🔔 新增成功彈出式提醒框 (Messages) | 防止空白表單提交,引入 django.contrib.messages,在新增/刪除成功後彈出綠色 Alert 提示,對應 Day 3 的 try-except 警示。 |
📌 課程前導與簡介說明
- 營隊定位:實現全套網頁 CRUD 操作。從傳統「唯讀 (Read-only)」網站跨越至「可互動 (Interactive)」動態 Web 應用。
- 高中生心理亮點:當學生在網頁按下【新增消費】,網頁自動刷新並彈出「✅ 新增成功」綠色提示框,接著資料真的出現在下方表格時,會感到無比震撼!
- 教學銜接點:GET vs POST (GET 像看菜單,POST 像下訂單);`{% csrf_token %}` 是 Django 預設的安全性門神,沒加必報 403 Forbidden 錯誤。
歡迎來到 Day 9!今天我們要讓網站具備真正的「互動寫入與刪除能力」!請閱讀每個單元的 **故事單元說明**,點擊 **延伸閱讀** 展開深層觀念與 Google 搜尋,並善用每區塊的 **AI 協作除錯提示詞** 來練習撰寫表單與控制邏輯!
💻 Day 9 專案總成果:具備表單新增、垃圾桶刪除與分類搜尋的 Django 記帳網站
【教師參考解答】學生於 Day 9 結束時將完成的 URL、View 與 HTML 表單程式碼:
1. 路由對映 (`expenses/urls.py`):
from django.urls import path
from . import views
urlpatterns = [
path('', views.home, name='home'),
path('add/', views.add_expense, name='add_expense'), # 新增消費路由
path('delete/<int:pk>/', views.delete_expense, name='delete_expense'), # 動態刪除路由
]
2. 互動 View 視圖 (`expenses/views.py`):
from django.shortcuts import render, redirect, get_object_or_404
from django.contrib import messages
from .models import Expense
from django.db.models import Sum
def home(request):
""" 首頁:支援分類過濾與總額統計 """
category_query = request.GET.get('category', '').strip()
if category_query:
records = Expense.objects.filter(category=category_query).order_by('-date')
else:
records = Expense.objects.all().order_by('-date')
total_cost = records.aggregate(Sum('price'))['price__sum'] or 0
context = {
'records': records,
'total_cost': total_cost,
'category_query': category_query,
}
return render(request, 'expenses/home.html', context)
def add_expense(request):
""" 處理 POST 表單提交與新增消費 """
if request.method == 'POST':
item = request.POST.get('item', '').strip()
price = request.POST.get('price', '').strip()
category = request.POST.get('category', '').strip() or '一般'
# 表單防呆驗證
if not item or not price:
messages.error(request, '❌ 請填寫完整的品項名稱與金額!')
return redirect('home')
try:
Expense.objects.create(
item=item,
price=int(price),
category=category
)
messages.success(request, f'✅ 成功新增消費:{item} ${price} 元!')
except ValueError:
messages.error(request, '❌ 金額必須為純數字!')
return redirect('home')
return redirect('home')
def delete_expense(request, pk):
""" 依 ID 刪除特定消費紀錄 """
expense = get_object_or_404(Expense, pk=pk)
item_name = expense.item
expense.delete()
messages.warning(request, f'🗑️ 已刪除紀錄:{item_name}')
return redirect('home')
3. 首頁 UI 與表單模板 (`templates/expenses/home.html`):
{% extends 'base.html' %}
{% block content %}
<!-- Flash 提醒訊息彈窗 -->
{% if messages %}
{% for message in messages %}
<div class="alert alert-{{ message.tags }} alert-dismissible fade show shadow-sm" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endfor %}
{% endif %}
<!-- 新增消費表單卡片 -->
<div class="card shadow-sm border-0 mb-4">
<div class="card-header bg-primary text-white fw-bold">➕ 新增消費紀錄</div>
<div class="card-body">
<form action="{% url 'add_expense' %}" method="POST" class="row g-3">
{% csrf_token %} <!-- 🛡️ CSRF 安全防偽印章 -->
<div class="col-md-4">
<input type="text" name="item" class="form-control" placeholder="品項名稱 (例如: 珍珠奶茶)" required>
</div>
<div class="col-md-3">
<input type="number" name="price" class="form-control" placeholder="金額 (TWD)" required>
</div>
<div class="col-md-3">
<input type="text" name="category" class="form-control" placeholder="分類 (例如: 餐飲)">
</div>
<div class="col-md-2">
<button type="submit" class="btn class-btn btn-success w-100 fw-bold">新增記帳</button>
</div>
</form>
</div>
</div>
<!-- 分類搜尋過濾列與明細表格 -->
<div class="card shadow-sm border-0">
<div class="card-header bg-white d-flex justify-content-between align-items-center py-3">
<span class="fw-bold">📊 記帳明細列表</span>
<form method="GET" action="{% url 'home' %}" class="d-flex gap-2">
<input type="text" name="category" class="form-control form-control-sm" placeholder="搜尋分類..." value="{{ category_query }}">
<button type="submit" class="btn btn-sm btn-outline-primary">搜尋</button>
{% if category_query %}
<a href="{% url 'home' %}" class="btn btn-sm btn-outline-secondary">清除</a>
{% endif %}
</form>
</div>
<div class="card-body p-0">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>#</th>
<th>日期</th>
<th>品項名稱</th>
<th>分類</th>
<th>金額</th>
<th class="text-center">操作</th>
</tr>
</thead>
<tbody>
{% for r in records %}
<tr>
<td>{{ forloop.counter }}</td>
<td>{{ r.date|date:"Y-m-d" }}</td>
<td class="fw-bold">{{ r.item }}</td>
<td><span class="badge bg-secondary">{{ r.category }}</span></td>
<td class="text-success fw-bold">${{ r.price }}</td>
<td class="text-center">
<!-- 動態 URL 垃圾桶刪除按鈕 -->
<a href="{% url 'delete_expense' r.pk %}" class="btn btn-sm btn-outline-danger" onclick="return confirm('確定要刪除 [{{ r.item }}] 嗎?');">
🗑️ 刪除
</a>
</td>
</tr>
{% empty %}
<tr>
<td colspan="6" class="text-center py-4 text-muted">📭 目前尚無任何記帳紀錄!</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
【學生自主實作框架】請根據今日各單元所學,將空缺的 `____` 填入正確的 Django Form & POST 關鍵字:
<!-- 🎯 Day 9 自主挑戰:請填入 HTML 表單與 View 邏輯空缺的 ____ 關鍵字! -->
<!-- --- [檔案: templates/expenses/home.html] --- -->
<form action="{% url 'add_expense' %}" method="____"> # 提示:提交機密修改表單的 HTTP 方法 POST
{% ____ %} # 提示:必填的 CSRF 安全防偽印章標籤 csrf_token
<input type="text" name="item" placeholder="品項名稱">
<input type="number" name="price" placeholder="金額">
<button type="submit">送出</button>
</form>
# --- [檔案: expenses/views.py] ---
def add_expense(request):
if request.method == '____': # 提示:檢查請求是否為 POST
item_input = request.POST.____('item') # 提示:從 request.POST 字典取出欄位值的方法 get
price_input = request.POST.get('price')
Expense.objects.____(item=item_input, price=price_input) # 提示:ORM 創建資料的方法 create
return ____('home') # 提示:新增成功後重導向回首頁的函式 redirect
⏰ 單元 1:HTTP GET vs POST(09:00 - 10:00)
在網路世界上,讀取資料與寫入資料有著嚴格的分工:
• **HTTP GET**:像是「看菜單」,只用來讀取網頁(參數會裸露在網址列中,如 ?category=餐飲)。
• **HTTP POST**:像是「把點餐單遞給廚房」,用來傳送修改或新增機密資料(資料封裝在 Request Body 中不外露)。
我們會使用 HTML <form method="POST"> 來讓使用者填寫記帳資料!對應的就是 Day 1 的 input()!
網頁通訊協定對 HTTP 動詞的規範:
- GET 請求:安全 (Safe) 且冪等 (Idempotent),多次請求不影響伺服器狀態。資料長度受限於 URL 長度限制。
- POST 請求:非冪等 (Non-idempotent),會改變伺服器狀態(如新增資料庫紀錄)。資料放在 Body 中,長度無限制。
🔗 Google 搜尋關鍵字 (依相關度排序,紫羅蘭標記為進階內容,點擊開啟新分頁):
🤖 AI 自主學習探索提示詞 (Prompt):
請向初學者詳細說明 HTTP GET 與 POST 的本質差異,並舉例說明為什麼查詢網頁要用 GET,而提交表單新增資料一定要用 POST。
- 講解 GET (獲取/無副作用) 與 POST (提交/改變伺服器狀態)。
- 撰寫 HTML
<form action="..." method="POST">標籤結構。 - 概念連結:對應 Day 1 的
input()鍵盤輸入與資料收集。
<!-- HTML POST 表單基本結構 -->
<form action="{% url 'add_expense' %}" method="POST">
<input type="text" name="item" placeholder="品項名稱">
<input type="number" name="price" placeholder="金額">
<button type="submit">新增</button>
</form>
- input 漏寫 name 屬性:表單寫了
<input type="text">但漏寫name="item",導致 View 端request.POST.get('item')完全抓不到資料!
我在 View 端使用 request.POST.get('item') 時總是抓到 None。請幫我檢查 HTML 標籤中是否漏寫了 name="item" 屬性。
⏰ 單元 2:CSRF 防護與接收資料(10:00 - 11:00)
如果沒有防護,駭客可以偷偷複製你的點餐單,假冒你的名義偷偷幫你下單(這稱為 **跨站請求偽造 CSRF 攻擊**)。Django 極度重視網路安全,強制規定所有 POST 表單內部必須加上魔法印章 {% csrf_token %}!沒有這枚印章,Django 會毫不留情地拋出 **403 Forbidden** 阻擋攻擊!而在 View 端,我們用 request.POST.get('item') 就能安全解開顧客填寫的內容。
CSRF 是一種常見的 Web 網路攻擊手法:
- 攻擊原理:使用者登入 A 網站後,誘導點擊惡意 B 網站,B 網站利用瀏覽器的 Cookie 憑證發送偽造請求至 A 網站。
{% csrf_token %}防禦:Django 會為每個使用者會話生成一個的一次性加密令牌 (Token) 塞在表單中,驗證成功才允許寫入。
🔗 Google 搜尋關鍵字 (依相關度排序,紫羅蘭標記為進階內容,點擊開啟新分頁):
🤖 AI 自主學習探索提示詞 (Prompt):
請向初學者解釋什麼是 CSRF (跨站請求偽造) 攻擊,以及 Django 表單中為什麼必須寫 {% csrf_token %},沒寫時出現的 403 Forbidden 代表什麼意思。
- 在表單中加入
{% csrf_token %}標籤。 - 在 View 寫
item = request.POST.get('item')擷取提交資料。 - 故意帶學生不寫
{% csrf_token %},觀察 Django 彈出的 403 Forbidden 畫面。
<!-- HTML 表單內必加安全印章 -->
<form method="POST">
{% csrf_token %}
<input type="text" name="item">
</form>
# View 擷取資料邏輯
def add_expense(request):
if request.method == 'POST':
item = request.POST.get('item') # 抓取 name="item" 的輸入值
price = request.POST.get('price')
- 403 Forbidden 錯誤:送出 POST 表單時跳出 `CSRF verification failed. Request aborted.` $\rightarrow$ 忘記在 `
我在送出 POST 表單時跳出了 403 Forbidden (CSRF verification failed)。請提醒我如何在 HTML
⏰ 單元 3:實作「新增消費」功能(11:00 - 12:00)
當我們安全拿到表單資料後,主廚 View 會呼叫 ORM 的 Expense.objects.create(item=item, price=price) 將資料寫入 SQLite 保險箱!寫入成功後,我們使用 return redirect('home') 讓使用者的瀏覽器自動跳轉回首頁,看到最新加進去的那筆開銷!這完全對應 Day 5 的 handle_add() 邏輯!
在 Web 開發中,處理 POST 請求後最佳實踐是重導向 (Redirect):
- Post/Redirect/Get (PRG) 模式:若 POST 成功後直接
render()回傳網頁,使用者按重新整理 (F5) 會重複觸發表單提交(重複扣款/重複新增)。 redirect('home'):回傳 302 重導向,讓瀏覽器發起新的 GET 請求載入首頁,解決重複提交問題。
🔗 Google 搜尋關鍵字 (依相關度排序,紫羅蘭標記為進階內容,點擊開啟新分頁):
🤖 AI 自主學習探索提示詞 (Prompt):
請向初學者解釋什麼是 PRG (Post/Redirect/Get) 模式,以及為什麼 Django 表單新增成功後必須用 redirect('home') 而不能直接 render()。
- 使用
Expense.objects.create(...)寫入 SQLite。 - 匯入
redirect模組,傳回return redirect('home')。 - 概念連結:對應 Day 5
handle_add()的新增與存檔邏輯。
from django.shortcuts import render, redirect
from .models import Expense
def add_expense(request):
if request.method == 'POST':
item = request.POST.get('item')
price = request.POST.get('price')
# 寫入資料庫
Expense.objects.create(item=item, price=int(price))
# 重導向回首頁 (PRG 模式)
return redirect('home')
return redirect('home')
- 重刷頁面重複新增:沒有寫 `redirect()` 而是寫 `render()`,導致使用者在瀏覽器按 F5 重刷時一直跳出「是否重複送出表單」。
我在表單新增成功後重新整理網頁會重複寫入相同的資料。請幫我把 return render(...) 改為 return redirect('home')。
⏰ 單元 4:動態 URL 與刪除功能(13:00 - 14:00)
要刪除特定的一筆資料,我們必須知道它的身份 ID!我們在 urls.py 使用動態網址參數 path('delete/<int:pk>/', ...)。當使用者點擊網頁表格旁邊的【🗑️ 刪除】按鈕時,網址會帶著這筆資料的 Primary Key (如 /delete/5/)。View 收到 pk=5 後,執行 Expense.objects.get(pk=5).delete() 就能精準刪除它!對應 Day 5 的 handle_delete(index)!
Django 動態路由與安全的物件擷取:
- Path Converter
<int:pk>:匹配整數並自動轉換為 Python `int` 變數傳入 View 的參數中。 get_object_or_404(Model, pk=pk):若指定的 ID 在資料庫中找不到,自動傳回漂亮的 404 Not Found 頁面,避免拋出 500 內部崩潰。
🔗 Google 搜尋關鍵字 (依相關度排序,紫羅蘭標記為進階內容,點擊開啟新分頁):
🤖 AI 自主學習探索提示詞 (Prompt):
請說明 Django 動態路由 path('delete/<int:pk>/', ...) 的運作原理,並示範如何在 HTML 中寫出 {% url 'delete_expense' r.pk %} 連結以及在 View 使用 get_object_or_404() 進行安全刪除。
- 在
urls.py定義path('delete/<int:pk>/', ...)。 - 在 View 使用
get_object_or_404(Expense, pk=pk)並呼叫.delete()。 - 在 HTML 表格旁加入帶參數的 URL 標籤
{% url 'delete_expense' r.pk %}。 - 概念連結:對應 Day 5
handle_delete(index)依 ID 刪除。
# 1. View 端刪除邏輯
def delete_expense(request, pk):
expense = get_object_or_404(Expense, pk=pk)
expense.delete() # 執行 ORM 刪除
return redirect('home')
<!-- 2. HTML 端超連結帶入 pk -->
<a href="{% url 'delete_expense' r.pk %}" class="btn btn-danger btn-sm">🗑️ 刪除</a>
- NoReverseMatch 錯誤:在 HTML 使用 `{% url 'delete_expense' %}` 時忘記帶入變數參數 `r.pk` $\rightarrow$ 報錯 `NoReverseMatch at / : Reverse for 'delete_expense' with no arguments not found.`!
我在 HTML 寫 {% url 'delete_expense' %} 時跳出了 NoReverseMatch 錯誤。請提醒我如何在 URL 標籤末端補上 ID 變數 (例如 r.pk)。
⏰ 單元 5:URL 查詢參數與篩選(14:00 - 15:00)
當顧客想查「這個月花多少錢吃餐飲?」時,我們在網頁上方放一個 GET 搜尋表單。當點擊【搜尋】時,網址會帶有 Query Parameter(如 http://127.0.0.1:8000/?category=餐飲)。View 主廚用 request.GET.get('category') 抓出這個字串,並呼叫 Expense.objects.filter(category=cat) 進行精準過濾!對應 Day 5 的 handle_filter()!
GET 查詢參數常用於搜尋、過濾與分頁功能:
request.GET.get('key'):取出網址?key=value的參數值。- 進階 ORM 模糊查詢:使用
item__icontains=keyword可實現不分大小寫的關鍵字模糊搜尋。
🔗 Google 搜尋關鍵字 (依相關度排序,紫羅蘭標記為進階內容,點擊開啟新分頁):
🤖 AI 自主學習探索提示詞 (Prompt):
請說明如何使用 HTML <form method="GET"> 傳送網址參數,並示範在 Django View 中用 request.GET.get() 搭配 Expense.objects.filter() 完成搜尋過濾。
- 在 View 寫
cat = request.GET.get('category')。 - 若有值則執行
Expense.objects.filter(category=cat)。 - 概念連結:對應 Day 5
handle_filter()的分類搜尋。
# View 端處理 GET 查詢參數
def home(request):
cat_query = request.GET.get('category', '').strip()
if cat_query:
records = Expense.objects.filter(category=cat_query)
else:
records = Expense.objects.all()
return render(request, 'expenses/home.html', {'records': records})
- 搜尋表單寫成 method="POST":搜尋過濾屬於讀取操作,必須使用 `method="GET"`,參數才會附在 URL 後方。
我的搜尋表單按送出後網址沒有出現 ?category=餐飲 的參數。請幫我檢查 <form> 的 method 屬性是否誤寫成了 POST 而不是 GET。
⏰ 單元 6:表單防呆與 Flash 訊息(15:00 - 16:00)
當使用者在表單不小心把金額空著、或是輸入英文數字時,程式不應該白屏當機!我們可以在 View 加入防呆判斷,並引入 Django 內建的 **Flash Messages 框架**!當新增或刪除成功時,呼叫 messages.success(request, '新增成功!'),網頁畫面上就會彈出超漂亮的綠色 Alert 提醒視窗!對應 Day 3 的 try-except 防呆提示!
Django 利用 Session 在跨重導向頁面間傳遞一次性訊息 (Flash Message):
- 常用訊息等級:
messages.success()(綠色成功)、messages.error()(紅色錯誤)、messages.warning()(黃色警告)。 - HTML 渲染
{% if messages %}:訊息顯示一次後會自動從 Session 中清除,不會重複彈出。
🔗 Google 搜尋關鍵字 (依相關度排序,紫羅蘭標記為進階內容,點擊開啟新分頁):
🤖 AI 自主學習探索提示詞 (Prompt):
請說明如何使用 django.contrib.messages 在 View 中傳送一次性成功或錯誤訊息,並示範如何在 HTML 中用 Bootstrap 5 Alert 彈窗印出 messages。
- 匯入
from django.contrib import messages。 - 在 View 防呆判斷時呼叫
messages.error()與messages.success()。 - 在 HTML 頂部加上
{% if messages %}走訪印出 Bootstrap Alert 視窗。 - 概念連結:對應 Day 3 的
try-except與防呆警示提示。
# 1. View 端發送一次性快訊
from django.contrib import messages
def add_expense(request):
if request.method == 'POST':
item = request.POST.get('item')
if not item:
messages.error(request, '❌ 品項名稱不能為空!')
return redirect('home')
messages.success(request, '✅ 成功新增一筆消費!')
return redirect('home')
<!-- 2. HTML 頂部顯示 Messages -->
{% if messages %}
{% for message in messages %}
<div class="alert alert-{{ message.tags }}">
{{ message }}
</div>
{% endfor %}
{% endif %}
- HTML 未走訪 messages:在 View 寫了 `messages.success()`,但 HTML 忘記寫 `{% if messages %}` 迴圈,導致訊息完全沒彈出來!
我在 View 呼叫了 messages.success() 但網頁上沒有跳出提示框。請幫我檢查 HTML 模板中是否漏寫了 {% if messages %} ... {% endif %} 區塊。