實作文章刪除功能¶
開始之前¶
任務目標
在這個章節中,我們會完成:
- 使用 GET 請求實作簡單的刪除功能
- 了解 GET 刪除的安全性問題
- 使用 POST + Form 實作安全的刪除確認
- 在文章詳情頁加入刪除按鈕
從簡單開始:GET 刪除¶
讓我們先實作一個最簡單的刪除功能,看看它有什麼問題。
建立簡單的刪除 View¶
在 blog/views.py 中新增刪除 view:
from django.shortcuts import get_object_or_404, redirect, render
from blog.forms import ArticleForm
from blog.models import Article
def article_list(request):
articles = Article.objects.select_related("author").prefetch_related("tags")
return render(request, "blog/article_list.html", {"articles": articles})
def article_detail(request, article_id):
article = get_object_or_404(
Article.objects.select_related("author").prefetch_related("tags"),
id=article_id,
)
return render(request, "blog/article_detail.html", {"article": article})
def article_create(request):
form = ArticleForm(request.POST or None)
if form.is_valid():
article = form.save()
return redirect("blog:article_detail", article_id=article.id)
return render(request, "blog/article_create.html", {"form": form})
def article_edit(request, article_id):
article = get_object_or_404(Article, id=article_id)
form = ArticleForm(request.POST or None, instance=article)
if form.is_valid():
article = form.save()
return redirect("blog:article_detail", article_id=article.id)
return render(request, "blog/article_edit.html", {"form": form, "article": article})
def article_delete(request, article_id): # (1)!
article = get_object_or_404(Article, id=article_id)
article.delete() # (2)!
return redirect("blog:article_list") # (3)!
- 建立刪除 view
- 直接呼叫
delete()方法刪除資料 - 刪除後重導向到文章列表
程式碼解析
這個 view 非常簡單:
- 取得要刪除的文章(找不到就回傳 404)
- 呼叫
delete()方法刪除 - 重導向到文章列表頁
只要訪問這個網址,文章就會被刪除!
設定 URL¶
在 blog/urls.py 加入刪除的 URL pattern:
from django.urls import path
from blog import views
app_name = "blog"
urlpatterns = [
path("articles/", views.article_list, name="article_list"),
path("articles/create/", views.article_create, name="article_create"),
path("articles/<int:article_id>/", views.article_detail, name="article_detail"),
path("articles/<int:article_id>/edit/", views.article_edit, name="article_edit"),
path("articles/<int:article_id>/delete/", views.article_delete, name="article_delete"),
]
測試簡單刪除¶
啟動開發伺服器:
直接在瀏覽器訪問(假設文章 ID 為 1):
http://127.0.0.1:8000/blog/articles/1/delete/
文章就被刪除了!是不是很簡單?
等等,這樣真的好嗎?
看起來很方便,但這個實作有嚴重的安全問題!
讓我們來看看為什麼不應該用 GET 方法處理刪除操作。
GET 刪除的問題¶
問題一:違反 HTTP 規範¶
HTTP 方法有明確的語意:
| 方法 | 語意 | 應用 | 副作用 |
|---|---|---|---|
| GET | 取得資源 | 讀取、查詢、顯示 | ❌ 不應有副作用 |
| POST | 提交資料 | 建立、更新、刪除 | ✅ 可以有副作用 |
| PUT | 完整更新 | 替換整個資源 | ✅ 可以有副作用 |
| DELETE | 刪除資源 | 刪除 | ✅ 可以有副作用 |
GET 應該是「安全」的
根據 HTTP 規範,GET 請求應該是「安全」的(safe),意思是:
- 只讀取資料,不修改伺服器狀態
- 可以被快取
- 可以被預先載入
- 可以重複執行
刪除操作明顯違反了這個原則!
問題二:容易被意外觸發¶
使用 GET 刪除會有很多意外觸發的風險:
1. 瀏覽器預先載入¶
2. 搜尋引擎爬蟲¶
3. 連結分享¶
4. 瀏覽器歷史紀錄¶
問題三:缺少 CSRF 保護¶
使用 GET 刪除容易受到 CSRF(Cross-Site Request Forgery,跨站請求偽造)攻擊。
攻擊者可以在自己的網站放置這段程式碼:
當使用者訪問攻擊者的網站時,瀏覽器會自動發送 GET 請求到你的網站(並帶上使用者的 Cookie),文章就被刪除了!
CSRF 攻擊威脅
使用 GET 刪除的風險:
- 攻擊者可以透過圖片、iframe 等方式觸發 GET 請求
- 瀏覽器會自動帶上 Cookie,伺服器以為是使用者本人操作
- 使用者完全不知情
這就是為什麼修改資料的操作必須使用 POST + CSRF Token!
問題四:缺少確認機制¶
這樣的使用者體驗很糟糕,容易造成誤刪。
正確的做法:使用 POST + 表單¶
要解決上述所有問題,我們需要:
- ✅ 使用 POST 方法(符合 HTTP 規範)
- ✅ 加上 CSRF Token(防止 CSRF 攻擊)
- ✅ 顯示確認頁面(避免誤刪)
- ✅ 明確的使用者操作(不會被意外觸發)
改寫刪除 View¶
修改 blog/views.py 中的 article_delete view:
from django.shortcuts import get_object_or_404, redirect, render
from blog.forms import ArticleForm
from blog.models import Article
def article_list(request):
articles = Article.objects.select_related("author").prefetch_related("tags")
return render(request, "blog/article_list.html", {"articles": articles})
def article_detail(request, article_id):
article = get_object_or_404(
Article.objects.select_related("author").prefetch_related("tags"),
id=article_id,
)
return render(request, "blog/article_detail.html", {"article": article})
def article_create(request):
form = ArticleForm(request.POST or None)
if form.is_valid():
article = form.save()
return redirect("blog:article_detail", article_id=article.id)
return render(request, "blog/article_create.html", {"form": form})
def article_edit(request, article_id):
article = get_object_or_404(Article, id=article_id)
form = ArticleForm(request.POST or None, instance=article)
if form.is_valid():
article = form.save()
return redirect("blog:article_detail", article_id=article.id)
return render(request, "blog/article_edit.html", {"form": form, "article": article})
def article_delete(request, article_id):
article = get_object_or_404(Article, id=article_id)
if request.method == "POST": # (1)!
article.delete()
return redirect("blog:article_list")
return render(request, "blog/article_delete.html", {"article": article}) # (2)!
- POST 請求:執行刪除
- GET 請求:顯示確認頁面
View 設計
這個 view 的運作方式:
- GET 請求:顯示確認頁面,詢問「確定要刪除嗎?」
- POST 請求:真正執行刪除操作
這樣就避免了所有 GET 刪除的問題!
建立確認頁面 Template¶
建立 blog/templates/blog/article_delete.html:
{% extends "blog/base.html" %}
{% block title %}
刪除文章:{{ article.title }} - Django 大冒險
{% endblock title %}
{% block blog_content %}
<nav aria-label="breadcrumb" class="d-none d-md-block mb-3">
<ol class="breadcrumb">
<li class="breadcrumb-item">
<a href="{% url 'blog:article_list' %}">文章列表</a>
</li>
<li class="breadcrumb-item">
<a href="{% url 'blog:article_detail' article_id=article.id %}">{{ article.title }}</a>
</li>
<li class="breadcrumb-item active">
刪除確認
</li>
</ol>
</nav>
<div class="card border-danger"> {# (1)! #}
<div class="card-header bg-danger text-white">
<h2 class="card-title mb-0">
<i class="bi bi-exclamation-triangle-fill"></i>
刪除確認
</h2>
</div>
<div class="card-body">
<div class="alert alert-danger" role="alert"> {# (2)! #}
<h5 class="alert-heading">
<i class="bi bi-exclamation-circle"></i>
警告:此操作無法復原!
</h5>
<p class="mb-0">
您確定要刪除這篇文章嗎?刪除後將無法恢復。
</p>
</div>
<div class="card mb-3"> {# (3)! #}
<div class="card-body">
<h3 class="card-title h5">
{{ article.title }}
</h3>
<p class="card-text text-muted">
<i class="bi bi-person"></i>
作者:{{ article.author.name }}
<span class="ms-3">
<i class="bi bi-calendar"></i>
發布時間:{{ article.created_at|date:"Y-m-d H:i" }}
</span>
</p>
<div class="card-text">
{{ article.content|truncatewords:50|linebreaks }}
</div>
</div>
</div>
<form method="post"> {# (4)! #}
{% csrf_token %} {# (5)! #}
<div class="d-flex gap-2">
<button type="submit" class="btn btn-danger">
<i class="bi bi-trash"></i>
確認刪除
</button>
<a href="{% url 'blog:article_detail' article_id=article.id %}"
class="btn btn-secondary">
<i class="bi bi-x-circle"></i>
取消
</a>
</div>
</form>
</div>
</div>
{% endblock blog_content %}
- 使用紅色主題警告危險操作
- 警告提示框
- 顯示即將刪除的文章預覽
- 使用 POST 表單
- 重要:CSRF Token 防止跨站攻擊
確認頁面設計重點
-
視覺警告:
- 紅色配色(danger)
- 警告圖示
- 明確的警告訊息
-
文章預覽:
- 顯示標題、作者、時間
- 顯示內容摘要(50 字)
- 讓使用者確認是否刪除正確的文章
-
操作按鈕:
- 「確認刪除」:紅色醒目
- 「取消」:灰色次要
- 兩個按鈕都很明顯
-
CSRF Token:
{% csrf_token %}必須放在 form 內- Django 會自動驗證
- 防止 CSRF 攻擊
測試刪除功能¶
啟動開發伺服器:
訪問刪除頁面(假設文章 ID 為 1):
http://127.0.0.1:8000/blog/articles/1/delete/
你會看到:
- 確認頁面:顯示警告訊息和文章預覽
- 兩個選擇:確認刪除 或 取消
- 按下確認:執行 POST 請求,刪除文章
- 重導向:回到文章列表
安全性提升
現在的刪除流程:
- ✅ 需要使用者明確點擊「確認刪除」
- ✅ 使用 POST 方法(符合 HTTP 規範)
- ✅ 有 CSRF Token 保護(防止攻擊)
- ✅ 不會被爬蟲、預載入意外觸發
- ✅ 使用者有機會反悔(按「取消」)
在文章詳情頁加入刪除按鈕¶
讓我們在文章詳情頁加入刪除按鈕。
修改 blog/templates/blog/article_detail.html:
{% extends "blog/base.html" %}
{% block title %}
{{ article.title }} - Django 大冒險
{% endblock title %}
{% block blog_content %}
<nav aria-label="breadcrumb" class="d-none d-md-block">
<ol class="breadcrumb">
<li class="breadcrumb-item">
<a href="{% url 'blog:article_list' %}">文章列表</a>
</li>
<li class="breadcrumb-item active">
{{ article.title }}
</li>
</ol>
</nav>
<article class="card">
<div class="card-body">
<h1 class="card-title fs-3 fs-md-2 fs-lg-1">
{{ article.title }}
</h1>
<div class="d-flex flex-column flex-md-row align-items-md-center mb-3 text-muted">
<span class="me-md-3 mb-2 mb-md-0">
<i class="bi bi-person"></i>
作者:{{ article.author.name }}
</span>
<span>
<i class="bi bi-calendar"></i>
發布時間:{{ article.created_at|date:"Y-m-d H:i" }}
</span>
</div>
{% if article.tags.exists %}
<div class="mb-3">
{% for tag in article.tags.all %}
<span class="badge bg-secondary me-1 mb-1">{{ tag.name }}</span>
{% endfor %}
</div>
{% endif %}
<hr />
<div class="article-content fs-6 fs-md-5">
{{ article.content|linebreaks }}
</div>
</div>
<div class="card-footer bg-light">
<div class="d-flex justify-content-between align-items-center">
<div class="d-flex gap-2">
<a href="{% url 'blog:article_list' %}"
class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left"></i>
<span class="d-none d-sm-inline">返回列表</span>
</a>
<a href="{% url 'blog:article_edit' article_id=article.id %}"
class="btn btn-outline-primary btn-sm">
<i class="bi bi-pencil"></i>
<span class="d-none d-sm-inline">編輯</span>
</a>
<a href="{% url 'blog:article_delete' article_id=article.id %}"
class="btn btn-outline-danger btn-sm">
<i class="bi bi-trash"></i>
<span class="d-none d-sm-inline">刪除</span>
</a>
</div>
<small class="text-muted d-none d-md-inline">
最後更新:{{ article.updated_at|date:"Y-m-d" }}
</small>
</div>
</div>
</article>
{% endblock blog_content %}
刪除按鈕設計
-
顏色選擇:
- 使用
btn-outline-danger(紅色外框) - 與編輯按鈕區分
- 警告這是危險操作
- 使用
-
位置安排:
- 放在編輯按鈕旁邊
- 保持操作按鈕的一致性
-
圖示:
- 使用
bi-trash(垃圾桶圖示) - 直覺表達刪除的意思
- 使用
-
連結而非表單:
- 這裡只是「連結到確認頁面」
- 真正的刪除在確認頁面才發生
- 符合兩階段操作的設計
常見問題¶
為什麼不使用 JavaScript 確認對話框?¶
有些人會用 JavaScript 的 confirm():
問題:
- ❌ 還是 GET 請求,沒有解決安全問題
- ❌ JavaScript 可以被停用
- ❌ 無法顯示豐富的資訊(如文章預覽)
- ❌ 樣式難以客製化
正確做法:
- ✅ 使用專門的確認頁面
- ✅ POST 請求 + CSRF Token
- ✅ 可以顯示詳細資訊
- ✅ 更好的使用者體驗
可以用 DELETE 方法嗎?¶
HTML 表單只支援 GET 和 POST,不支援 DELETE、PUT 等方法。
如果要使用 DELETE:
- 方法一:使用 JavaScript 發送 AJAX 請求
- 方法二:使用隱藏欄位模擬(Django 不直接支援)
對於一般網站,使用 POST 就足夠了。RESTful API 才需要用到 DELETE。
如何實作軟刪除?¶
有時候我們不想真的刪除資料,而是標記為「已刪除」:
class Article(models.Model):
# ... 其他欄位 ...
is_deleted = models.BooleanField(default=False)
deleted_at = models.DateTimeField(null=True, blank=True)
# View 中
def article_delete(request, article_id):
article = get_object_or_404(Article, id=article_id)
if request.method == "POST":
article.is_deleted = True
article.deleted_at = timezone.now()
article.save() # 不是 delete()!
return redirect("blog:article_list")
return render(request, "blog/article_delete.html", {"article": article})
# 查詢時過濾已刪除的
Article.objects.filter(is_deleted=False)
好處是可以復原,但會讓邏輯變複雜。
任務結束¶
完成!
恭喜你完成了這個章節!現在你已經:
- 使用 GET 請求實作簡單的刪除功能
- 了解 GET 刪除的安全性問題
- 使用 POST + Form 實作安全的刪除確認
- 在文章詳情頁加入刪除按鈕
你學會了:
- HTTP 方法的正確使用:GET 用於讀取,POST 用於修改
- CSRF 保護的重要性:所有修改操作都需要 CSRF Token
- 兩階段確認的設計:避免誤刪,提升使用者體驗