跳轉到

實作文章刪除功能

開始之前

任務目標

在這個章節中,我們會完成:

  • 使用 GET 請求實作簡單的刪除功能
  • 了解 GET 刪除的安全性問題
  • 使用 POST + Form 實作安全的刪除確認
  • 在文章詳情頁加入刪除按鈕

從簡單開始:GET 刪除

讓我們先實作一個最簡單的刪除功能,看看它有什麼問題。

建立簡單的刪除 View

blog/views.py 中新增刪除 view:

blog/views.py
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)!
  1. 建立刪除 view
  2. 直接呼叫 delete() 方法刪除資料
  3. 刪除後重導向到文章列表

程式碼解析

這個 view 非常簡單:

  1. 取得要刪除的文章(找不到就回傳 404)
  2. 呼叫 delete() 方法刪除
  3. 重導向到文章列表頁

只要訪問這個網址,文章就會被刪除!

設定 URL

blog/urls.py 加入刪除的 URL pattern:

blog/urls.py
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"),
]

測試簡單刪除

啟動開發伺服器:

uv run manage.py runserver

直接在瀏覽器訪問(假設文章 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. 搜尋引擎爬蟲

Google Bot 等爬蟲會訪問網站上的所有連結:
→ 爬蟲訪問了 /articles/1/delete/
→ 文章被刪除了!

3. 連結分享

使用者不小心把刪除連結傳給別人:
→ 對方點開連結
→ 文章被刪除了!

4. 瀏覽器歷史紀錄

使用者重新整理頁面或點擊「上一頁」:
→ 瀏覽器重新發送 GET 請求
→ 又刪除了一篇文章!

問題三:缺少 CSRF 保護

使用 GET 刪除容易受到 CSRF(Cross-Site Request Forgery,跨站請求偽造)攻擊。

攻擊者可以在自己的網站放置這段程式碼:

攻擊者網站.html
<!-- 攻擊者在自己的網站放置這段程式碼 -->
<img src="http://yoursite.com/blog/articles/1/delete/" />

當使用者訪問攻擊者的網站時,瀏覽器會自動發送 GET 請求到你的網站(並帶上使用者的 Cookie),文章就被刪除了!

CSRF 攻擊威脅

使用 GET 刪除的風險:

  • 攻擊者可以透過圖片、iframe 等方式觸發 GET 請求
  • 瀏覽器會自動帶上 Cookie,伺服器以為是使用者本人操作
  • 使用者完全不知情

這就是為什麼修改資料的操作必須使用 POST + CSRF Token!

問題四:缺少確認機制

使用者點擊「刪除」:
→ 沒有任何確認
→ 沒有任何提示
→ 直接刪除!
→ 無法復原!

這樣的使用者體驗很糟糕,容易造成誤刪。

正確的做法:使用 POST + 表單

要解決上述所有問題,我們需要:

  1. ✅ 使用 POST 方法(符合 HTTP 規範)
  2. ✅ 加上 CSRF Token(防止 CSRF 攻擊)
  3. ✅ 顯示確認頁面(避免誤刪)
  4. ✅ 明確的使用者操作(不會被意外觸發)

改寫刪除 View

修改 blog/views.py 中的 article_delete view:

blog/views.py
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)!
  1. POST 請求:執行刪除
  2. GET 請求:顯示確認頁面

View 設計

這個 view 的運作方式:

  • GET 請求:顯示確認頁面,詢問「確定要刪除嗎?」
  • POST 請求:真正執行刪除操作

這樣就避免了所有 GET 刪除的問題!

建立確認頁面 Template

建立 blog/templates/blog/article_delete.html

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 %}
  1. 使用紅色主題警告危險操作
  2. 警告提示框
  3. 顯示即將刪除的文章預覽
  4. 使用 POST 表單
  5. 重要:CSRF Token 防止跨站攻擊

確認頁面設計重點

  1. 視覺警告

    • 紅色配色(danger)
    • 警告圖示
    • 明確的警告訊息
  2. 文章預覽

    • 顯示標題、作者、時間
    • 顯示內容摘要(50 字)
    • 讓使用者確認是否刪除正確的文章
  3. 操作按鈕

    • 「確認刪除」:紅色醒目
    • 「取消」:灰色次要
    • 兩個按鈕都很明顯
  4. CSRF Token

    • {% csrf_token %} 必須放在 form 內
    • Django 會自動驗證
    • 防止 CSRF 攻擊

測試刪除功能

啟動開發伺服器:

uv run manage.py runserver

訪問刪除頁面(假設文章 ID 為 1):

http://127.0.0.1:8000/blog/articles/1/delete/

你會看到:

  1. 確認頁面:顯示警告訊息和文章預覽
  2. 兩個選擇:確認刪除 或 取消
  3. 按下確認:執行 POST 請求,刪除文章
  4. 重導向:回到文章列表

安全性提升

現在的刪除流程:

  1. ✅ 需要使用者明確點擊「確認刪除」
  2. ✅ 使用 POST 方法(符合 HTTP 規範)
  3. ✅ 有 CSRF Token 保護(防止攻擊)
  4. ✅ 不會被爬蟲、預載入意外觸發
  5. ✅ 使用者有機會反悔(按「取消」)

在文章詳情頁加入刪除按鈕

讓我們在文章詳情頁加入刪除按鈕。

修改 blog/templates/blog/article_detail.html

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 %}

刪除按鈕設計

  1. 顏色選擇

    • 使用 btn-outline-danger(紅色外框)
    • 與編輯按鈕區分
    • 警告這是危險操作
  2. 位置安排

    • 放在編輯按鈕旁邊
    • 保持操作按鈕的一致性
  3. 圖示

    • 使用 bi-trash(垃圾桶圖示)
    • 直覺表達刪除的意思
  4. 連結而非表單

    • 這裡只是「連結到確認頁面」
    • 真正的刪除在確認頁面才發生
    • 符合兩階段操作的設計

常見問題

為什麼不使用 JavaScript 確認對話框?

有些人會用 JavaScript 的 confirm()

不建議的做法
<a href="/articles/1/delete/"
   onclick="return confirm('確定要刪除嗎?')">
  刪除
</a>

問題

  1. ❌ 還是 GET 請求,沒有解決安全問題
  2. ❌ JavaScript 可以被停用
  3. ❌ 無法顯示豐富的資訊(如文章預覽)
  4. ❌ 樣式難以客製化

正確做法

  • ✅ 使用專門的確認頁面
  • ✅ POST 請求 + CSRF Token
  • ✅ 可以顯示詳細資訊
  • ✅ 更好的使用者體驗

可以用 DELETE 方法嗎?

HTML 表單只支援 GET 和 POST,不支援 DELETE、PUT 等方法。

如果要使用 DELETE:

  1. 方法一:使用 JavaScript 發送 AJAX 請求
  2. 方法二:使用隱藏欄位模擬(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 實作安全的刪除確認
  • 在文章詳情頁加入刪除按鈕

你學會了:

  1. HTTP 方法的正確使用:GET 用於讀取,POST 用於修改
  2. CSRF 保護的重要性:所有修改操作都需要 CSRF Token
  3. 兩階段確認的設計:避免誤刪,提升使用者體驗