跳轉到

圖片與檔案上傳

開始之前

任務目標

在這個任務中,你將學習:

  • 了解 Django 的檔案上傳機制
  • 設定 MEDIA_ROOT 和 MEDIA_URL
  • 使用 FileField 和 ImageField
  • 處理上傳檔案的表單
  • 在模板中顯示上傳的圖片
  • 圖片驗證與安全性
  • 使用 django-cleanup 自動管理檔案

在這個任務中,我們將為 Article 模型新增封面圖片的功能,學習如何在 Django 中處理檔案上傳。

Django 的媒體檔案設定

在 Django 中,使用者上傳的檔案稱為「媒體檔案」(Media Files),與開發者提供的「靜態檔案」(Static Files)是分開管理的。

graph LR
    A[靜態檔案 Static Files] --> B[開發者提供]
    A --> C[CSS, JavaScript, 圖片等]
    A --> D[STATIC_URL, STATIC_ROOT]

    E[媒體檔案 Media Files] --> F[使用者上傳]
    E --> G[頭像, 文章圖片, 附件等]
    E --> H[MEDIA_URL, MEDIA_ROOT]

靜態檔案與媒體檔案設定

在開始處理檔案上傳之前,讓我們先確認靜態檔案和媒體檔案的設定都已完成。

開啟 core/settings.py,找到靜態檔案的設定區塊(約在第 134 行),補充完整的設定:

core/settings.py
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/5.2/howto/static-files/

STATIC_URL = "static/"

STATICFILES_DIRS = [BASE_DIR / "static"]  # (1)!

STATIC_ROOT = BASE_DIR / "assets"  # (2)!

# Media files (User uploads)

MEDIA_URL = "media/"  # (3)!

MEDIA_ROOT = BASE_DIR / "media"  # (4)!
  1. STATICFILES_DIRS:開發環境中靜態檔案的來源目錄列表
  2. STATIC_ROOT:執行 collectstatic 時,收集所有靜態檔案的目標目錄(生產環境使用)
  3. MEDIA_URL:存取媒體檔案的 URL 前綴
  4. MEDIA_ROOT:媒體檔案在伺服器上的儲存路徑

接著我們來建立 STATICFILES_DIRS 設定的資料夾

mkdir static

靜態檔案設定說明

Django 會從以下兩個地方尋找靜態檔案:

  1. STATICFILES_DIRS:專案層級的靜態檔案目錄(如 BASE_DIR / "static"
  2. 各 APP 的 static 資料夾:Django 會自動尋找每個已安裝 APP 中的 static 目錄

例如,如果你在 blog app 中建立 blog/static/blog/images/logo.png,就可以在 template 中使用:

{% load static %}
<img src="{% static 'blog/images/logo.png' %}">

APP 靜態檔案的命名空間

注意到我們在 blog/static/ 下又建立了 blog/ 子資料夾嗎?這是為了建立「命名空間」,避免不同 APP 的靜態檔案名稱衝突。

blog/
├── static/
│   └── blog/           ← 建立與 app 同名的子資料夾
│       ├── css/
│       ├── js/
│       └── images/

這樣即使多個 APP 都有 logo.png,也不會互相覆蓋。

STATIC_ROOT 是執行 python manage.py collectstatic 時,Django 會將所有靜態檔案(包括 STATICFILES_DIRS 和各 APP 的 static)收集到這個目錄,供生產環境的 Web 伺服器使用。

MEDIA_ROOT 與 MEDIA_URL 的關係

假設我們上傳了一個檔案,它被儲存在 BASE_DIR / "media" / "articles" / "cover.jpg"

  • 實際檔案路徑/path/to/project/media/articles/cover.jpg
  • 存取 URL/media/articles/cover.jpg

Django 會自動處理這個對應關係。

開發環境的檔案服務設定

在開發環境中,我們需要在 urls.py 中加入設定,讓 Django 開發伺服器能夠提供靜態檔案和媒體檔案的存取。

開啟 core/urls.py,在檔案開頭加入 static 的匯入(第 2 行),並在底部的 DEBUG 區塊中加入檔案的 URL 設定:

core/urls.py
from django.conf import settings
from django.conf.urls.static import static  # (1)!
from django.contrib import admin
from django.contrib.auth import views as auth_views
from django.urls import include, path, reverse_lazy

from core import views

auth_urlpatterns = [
    path(
        "login/",
        auth_views.LoginView.as_view(template_name="registration/login.html"),
        name="login",
    ),
    path(
        "logout/",
        auth_views.LogoutView.as_view(),
        name="logout",
    ),
    path("register/", views.register, name="register"),
    path(
        "password-change/",
        auth_views.PasswordChangeView.as_view(
            template_name="registration/password_change.html",
            success_url=reverse_lazy("auth:password_change_done"),
        ),
        name="password_change",
    ),
    path(
        "password-change/done/",
        auth_views.PasswordChangeDoneView.as_view(
            template_name="registration/password_change_done.html"
        ),
        name="password_change_done",
    ),
    path(
        "password-reset/",
        auth_views.PasswordResetView.as_view(
            template_name="registration/password_reset.html",
            success_url=reverse_lazy("auth:password_reset_done"),
        ),
        name="password_reset",
    ),
    path(
        "password-reset/done/",
        auth_views.PasswordResetDoneView.as_view(
            template_name="registration/password_reset_done.html"
        ),
        name="password_reset_done",
    ),
    path(
        "password-reset/<uidb64>/<token>/",
        auth_views.PasswordResetConfirmView.as_view(
            template_name="registration/password_reset_confirm.html",
            success_url=reverse_lazy("auth:password_reset_complete"),
        ),
        name="password_reset_confirm",
    ),
    path(
        "password-reset/complete/",
        auth_views.PasswordResetCompleteView.as_view(
            template_name="registration/password_reset_complete.html"
        ),
        name="password_reset_complete",
    ),
]

urlpatterns = [
    path("admin/", admin.site.urls),
    path("practices/", include("practices.urls")),
    path("blog/", include("blog.urls")),
    path("auth/", include((auth_urlpatterns, "auth"))),
]

if settings.DEBUG:
    from debug_toolbar.toolbar import debug_toolbar_urls

    urlpatterns = [
        *urlpatterns,
        *debug_toolbar_urls(),
        *static(settings.STATIC_URL, document_root=settings.STATIC_ROOT),  # (2)!
        *static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT),  # (3)!
    ]
  1. 匯入 static 函式(settings 已經在檔案開頭匯入了)
  2. DEBUG 模式下,加入靜態檔案的 URL 設定
  3. DEBUG 模式下,加入媒體檔案的 URL 設定

為什麼需要這些設定?

在開發環境中:

  • 靜態檔案static() 函式會告訴 Django 開發伺服器如何提供 STATICFILES_DIRS 中的靜態檔案
  • 媒體檔案static() 函式會告訴 Django 開發伺服器如何提供 MEDIA_ROOT 中的上傳檔案

這樣當你在瀏覽器中存取 /static/.../media/... 時,Django 開發伺服器就知道要從哪個目錄提供對應的檔案。

生產環境的檔案處理

這些設定只適用於開發環境!在生產環境中:

  • 靜態檔案:應該先執行 collectstatic 收集到 STATIC_ROOT,然後由 Web 伺服器(如 Nginx、Apache)提供
  • 媒體檔案:應該由 Web 伺服器或雲端儲存服務(如 AWS S3)來處理

不應該由 Django 應用程式來處理檔案服務,這會影響效能和安全性。

Model 中的檔案欄位

Django 提供了兩種欄位類型來處理檔案上傳:FileFieldImageField

FileField

FileField 是用來處理一般檔案上傳的欄位,可以上傳任何類型的檔案。

models.py
from django.db import models

class Document(models.Model):
    title = models.CharField(max_length=200)
    file = models.FileField(upload_to="documents/")  # (1)!
    uploaded_at = models.DateTimeField(auto_now_add=True)
  1. upload_to 參數指定檔案上傳後的儲存路徑(相對於 MEDIA_ROOT

當使用者上傳檔案後,Django 會:

  1. 將檔案儲存到 MEDIA_ROOT / "documents" / "檔案名稱"
  2. 在資料庫中儲存相對路徑 documents/檔案名稱

ImageField

ImageFieldFileField 的子類別,專門用來處理圖片上傳。它會在上傳時驗證檔案是否為有效的圖片格式。

models.py
from django.db import models

class Article(models.Model):
    title = models.CharField(max_length=200)
    cover_image = models.ImageField(upload_to="articles/covers/")  # (1)!
    created_at = models.DateTimeField(auto_now_add=True)
  1. 使用 ImageField 來處理圖片上傳

ImageField 與 FileField 的差異

ImageField 在上傳時會驗證:

  • 檔案是否為有效的圖片格式(PNG, JPEG, GIF 等)
  • 圖片是否能被正常開啟和讀取

如果驗證失敗,Django 會拋出例外。此外,ImageField 還提供了 widthheight 屬性來取得圖片的尺寸。

安裝 Pillow

使用 ImageField 需要安裝 Pillow 套件。Pillow 是 Python 的圖片處理函式庫,Django 會使用它來驗證圖片格式、讀取圖片尺寸等。

使用 uv 安裝 Pillow:

uv add pillow

為什麼需要 Pillow?

Django 的 ImageField 在上傳時會使用 Pillow 來:

  1. 驗證檔案是否為有效的圖片格式
  2. 讀取圖片的尺寸資訊(width 和 height)
  3. 確保圖片可以被正常開啟和讀取

如果沒有安裝 Pillow,使用 ImageField 時會出現錯誤。

現在讓我們為 Article 模型新增封面圖片欄位。開啟 blog/models.py

blog/models.py
from django.conf import settings
from django.db import models


class Author(models.Model):
    name = models.CharField(max_length=100)
    email = models.EmailField(unique=True)
    bio = models.TextField(blank=True)
    created_at = models.DateTimeField(auto_now_add=True)

    def __str__(self):
        return self.name


class Tag(models.Model):
    name = models.CharField(max_length=50, unique=True)

    def __str__(self):
        return self.name


class Article(models.Model):
    title = models.CharField(max_length=200)
    content = models.TextField()
    cover_image = models.ImageField(  # (1)!
        upload_to="articles/covers/",
        blank=True,
        null=True,
    )
    created_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
    )
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)
    is_published = models.BooleanField(default=False)

    author = models.ForeignKey(
        Author,
        on_delete=models.CASCADE,
        related_name="articles",
        null=True,
        blank=True,
    )

    tags = models.ManyToManyField(
        Tag,
        related_name="articles",
        blank=True,
    )

    def __str__(self):
        return self.title
  1. 新增 cover_image 欄位,設定為可選(blank=True, null=True

upload_to 參數

upload_to 參數用來指定檔案的儲存路徑。它可以接受字串、格式化字串或函式。

使用字串

當使用字串時,所有上傳的檔案都會儲存在同一個目錄下:

models.py
cover_image = models.ImageField(upload_to="articles/covers/")

這樣所有文章的封面圖片都會儲存在 MEDIA_ROOT/articles/covers/ 目錄下。

使用格式化字串

Django 支援使用格式化字串來動態組織上傳路徑,會根據檔案上傳的日期時間自動建立目錄:

models.py
cover_image = models.ImageField(upload_to="articles/covers/%Y/%m/%d/")  # (1)!
  1. %Y%m%d 會被替換為年、月、日

支援的格式化選項:

  • %Y:四位數年份(如 2024
  • %m:兩位數月份(如 0112
  • %d:兩位數日期(如 0131
  • %H%M%S:時、分、秒

例如,在 2024 年 12 月 27 日上傳的檔案會儲存在 MEDIA_ROOT/articles/covers/2024/12/27/filename.jpg

使用函式動態設定路徑

如果需要更複雜的邏輯,可以使用函式來動態設定上傳路徑。例如,根據使用者 ID 來分類檔案:

models.py
def article_cover_upload_to(instance, filename):  # (1)!
    """
    動態設定文章封面圖片的上傳路徑。

    Args:
        instance: Article 模型實例
        filename: 原始檔案名稱

    Returns:
        str: 上傳路徑
    """
    user_id = instance.created_by.id if instance.created_by else 'anonymous'  # (2)!
    return f"articles/users/{user_id}/{filename}"  # (3)!


class Article(models.Model):
    title = models.CharField(max_length=200)
    cover_image = models.ImageField(upload_to=article_cover_upload_to)  # (4)!
    created_by = models.ForeignKey(User, on_delete=models.CASCADE)
  1. upload_to 函式接受兩個參數:instance(模型實例)和 filename(原始檔案名稱)
  2. 取得建立者的 ID,如果沒有則使用 'anonymous'
  3. 組合路徑:articles/users/使用者ID/檔案名稱
  4. 將函式傳給 upload_to 參數(注意不要加括號)

這樣上傳的檔案會儲存在 MEDIA_ROOT/articles/users/1/filename.jpg 這樣的路徑下。

instance 的狀態

使用函式時要注意:

  1. 新建立的實例:如果是第一次儲存,instance.pk 會是 None,某些欄位(如 auto_now_add 的欄位)可能還沒有值
  2. 外鍵關聯:確保外鍵欄位已經設定,否則可能會出現 AttributeError
  3. 避免依賴尚未儲存的資料:在函式中盡量使用已經設定的資料(如外鍵)

進階:避免檔名衝突

雖然 Django 有自己處理檔名重複問題,但如果希望自己處理,可以使用 UUID 來生成唯一的檔名:

models.py
import uuid
from pathlib import Path


def article_cover_upload_to(instance, filename):
    ext = Path(filename).suffix  # 取得副檔名(如 .jpg)
    unique_filename = f"{uuid.uuid4()}{ext}"  # 生成唯一檔名
    user_id = instance.created_by.id if instance.created_by else 'anonymous'
    return f"articles/users/{user_id}/{unique_filename}"

這樣即使使用者上傳同名檔案,也不會發生衝突。

最後別忘了執行 makemigrations 與 migrate 套用 Model 的變更

uv run manage.py makemigrations
uv run manage.py migrate

檔案上傳表單

要讓使用者能夠上傳檔案,我們需要在表單、View 和 Template 三個地方做相應的設定。

Form 設定

在 Django Form 中處理檔案上傳時,不需要特別的設定,只要確保 Form 包含了對應的欄位即可。

讓我們修改 blog/forms.py,在 ArticleForm 中加入 cover_image 欄位:

blog/forms.py
from django import forms

from blog.models import Article


class ArticleForm(forms.ModelForm):
    class Meta:
        model = Article
        fields = ["title", "content", "author", "cover_image"]  # (1)!
        labels = {
            "title": "標題",
            "content": "內容",
            "author": "作者",
        }
        error_messages = {
            "title": {
                "required": "標題不能空白",
                "max_length": "標題最多 %(limit_value)d 字元",
            },
            "content": {
                "required": "內容不能空白",
            },
        }
        widgets = {
            "content": forms.Textarea(attrs={"rows": 10}),
        }

    def clean_title(self):
        title = self.cleaned_data["title"]
        if "測試" in title:
            error_message = "標題不能包含「測試」"
            raise forms.ValidationError(error_message)

        return title
  1. fields 中加入 cover_image 欄位來處理封面圖片的上傳

View 處理

在 View 中處理檔案上傳時,需要注意以下幾點:

  1. 在處理 POST 請求時,需要同時傳入 request.FILES
  2. 檔案會透過 request.FILES 字典來傳遞

開啟 blog/views.py,修改 article_create 函式來處理檔案上傳:

blog/views.py
from django.contrib import messages
from django.contrib.auth.decorators import permission_required
from django.shortcuts import get_object_or_404, redirect, render

from blog.filters import ArticleFilter
from blog.forms import ArticleForm
from blog.models import Article


def article_list(request):
    filter_ = ArticleFilter(
        request.GET or None,
        queryset=Article.objects.select_related("author").prefetch_related("tags"),
    )
    return render(request, "blog/article_list.html", {"filter": filter_})


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


@permission_required("blog.add_article", raise_exception=True)
def article_create(request):
    form = ArticleForm(request.POST or None, request.FILES or None)  # (1)!
    if form.is_valid():
        article = form.save(commit=False)
        article.created_by = request.user
        article.save()
        messages.success(request, f"文章「{article.title}」已成功建立。")
        return redirect("blog:article_detail", article_id=article.id)

    return render(request, "blog/article_create.html", {"form": form})


@permission_required("blog.change_article", raise_exception=True)
def article_edit(request, article_id):
    article = get_object_or_404(Article, id=article_id)
    form = ArticleForm(request.POST or None, request.FILES or None, instance=article)  # (2)!
    if form.is_valid():
        article = form.save()
        messages.success(request, f"文章「{article.title}」已成功更新。")
        return redirect("blog:article_detail", article_id=article.id)

    return render(request, "blog/article_edit.html", {"form": form, "article": article})


@permission_required("blog.delete_article", raise_exception=True)
def article_delete(request, article_id):
    article = get_object_or_404(Article, id=article_id)

    if request.method == "POST":
        article.delete()
        messages.success(request, f"文章「{article.title}」已成功刪除。")
        return redirect("blog:article_list")

    return render(request, "blog/article_delete.html", {"article": article})
  1. 重要:在 article_create 中,需要同時傳入 request.POSTrequest.FILES
  2. 重要:在 article_edit 中也要加入 request.FILES,這樣才能更新封面圖片

不要忘記 request.FILES

如果沒有傳入 request.FILES,檔案上傳會失敗!這是新手常犯的錯誤。

# ❌ 錯誤:缺少 request.FILES
form = ArticleForm(request.POST or None)

# ✅ 正確:包含 request.FILES
form = ArticleForm(request.POST or None, request.FILES or None)

Template 中的檔案上傳

在 Template 中,我們需要在 <form> 標籤中加入 enctype="multipart/form-data" 屬性,這樣瀏覽器才會正確地傳送檔案資料。

修改 blog/templates/blog/components/article_form.html

blog/templates/blog/components/article_form.html
{% load django_bootstrap5 %}

{% bootstrap_form_errors form type='non_fields' %}

<form method="post" enctype="multipart/form-data">
  {% csrf_token %}

  {% bootstrap_field form.title addon_before='<i class="bi bi-pencil"></i>' placeholder="請輸入標題..." %}

  {% bootstrap_field form.content addon_before='<i class="bi bi-file-text"></i>' rows=10 %}

  {% bootstrap_field form.author addon_before='<i class="bi bi-person"></i>' %}

  {% bootstrap_field form.cover_image addon_before='<i class="bi bi-image"></i>' %}

  <div class="d-flex gap-2 mt-3">
    {% bootstrap_button button_type="submit" content=submit_text %}

    {% if show_reset %}
      {% bootstrap_button button_type="reset" content="清除內容" button_class="btn-info" %}
    {% endif %}

    {% bootstrap_button button_type="link" href=cancel_url content="取消" button_class="btn-secondary" %}
  </div>
</form>

enctype="multipart/form-data" 是必要的

如果沒有加入 enctype="multipart/form-data",檔案上傳會失敗!

  • 沒有設定:瀏覽器只會傳送檔案名稱,不會傳送檔案內容
  • 有設定:瀏覽器會正確地將檔案內容編碼並傳送
<!-- ❌ 錯誤:缺少 enctype -->
<form method="post">

<!-- ✅ 正確:包含 enctype -->
<form method="post" enctype="multipart/form-data">

顯示上傳的檔案

上傳檔案後,我們需要在 Template 中顯示這些檔案。

在 Template 中顯示圖片

Django 的 FileFieldImageField 提供了 .url 屬性,可以取得檔案的 URL。

讓我們修改文章列表和文章詳情頁面來顯示封面圖片。

首先,修改 blog/templates/blog/components/article_card.html(用於文章列表):

blog/templates/blog/components/article_card.html
<div class="col-12 col-sm-6 col-md-6 col-lg-4 col-xl-3 mb-4">
  <div class="card h-100">
    {% if article.cover_image %}  <!-- (1)! -->
      <img src="{{ article.cover_image.url }}" class="card-img-top" alt="{{ article.title }}" height="100%" width="100%">  <!-- (2)! -->
    {% endif %}
    <div class="card-body d-flex flex-column">
      <h5 class="card-title">
        {{ article.title }}
      </h5>
      <h6 class="card-subtitle mb-2 text-muted">
        {{ article.author.name }}
      </h6>
      {% if article.tags.exists %}
        <div class="mb-2">
          {% for tag in article.tags.all %}
            <span class="badge bg-secondary me-1">{{ tag.name }}</span>
          {% endfor %}
        </div>
      {% endif %}
      <p class="card-text flex-grow-1">
        {{ article.content|truncatewords:30 }}
      </p>
    </div>
    <div class="card-footer bg-transparent">
      <div class="d-flex justify-content-between align-items-center">
        <small class="text-muted">
          {{ article.created_at|date:"Y-m-d" }}
        </small>
        <a href="{% url 'blog:article_detail' article.id %}"
           class="btn btn-primary btn-sm">
          閱讀更多
        </a>
      </div>
    </div>
  </div>
</div>
  1. 檢查文章是否有封面圖片
  2. 使用 .url 屬性取得圖片的 URL

接著修改 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>

      {% if article.cover_image %}
        <div class="mb-3">
          <img src="{{ article.cover_image.url }}"
               class="img-fluid"
               alt="{{ article.title }}"
               height="100%"
               width="100%">
        </div>
      {% endif %}

      <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>
          {% if perms.blog.change_article %}
            <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>
          {% endif %}
          {% if perms.blog.delete_article %}
            <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>
          {% endif %}
        </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. 使用條件判斷

最簡單的方式就是使用 {% if %} 來判斷:

template.html
{% if article.cover_image %}
    <img src="{{ article.cover_image.url }}" alt="{{ article.title }}">
{% else %}
    <div class="no-image">沒有封面圖片</div>
{% endif %}

2. 提供預設圖片

我們也可以在沒有圖片時顯示預設圖片:

template.html
{% load static %}  <!-- (1)! -->

{% if article.cover_image %}
    <img src="{{ article.cover_image.url }}" alt="{{ article.title }}">
{% else %}
    <img src="{% static 'blog/images/default-cover.jpg' %}" alt="預設封面">  <!-- (2)! -->
{% endif %}
  1. 使用 {% static %} 標籤前需要先載入
  2. 使用 {% static %} 標籤來取得靜態檔案的 URL,路徑為 blog/images/default-cover.jpg(對應 blog/static/blog/images/default-cover.jpg

靜態檔案 vs 媒體檔案

這裡我們使用了靜態檔案(Static Files)來提供預設的封面圖片。讓我們釐清一下兩者的差異:

項目 靜態檔案(Static Files) 媒體檔案(Media Files)
來源 開發者提供 使用者上傳
用途 CSS、JavaScript、預設圖片、圖示等 使用者頭像、文章圖片、文件附件等
URL 前綴 /static/ /media/
儲存位置 STATICFILES_DIRS 或各 APP 的 static/ MEDIA_ROOT
Template 標籤 {% static 'path' %} 直接使用 {{ file.url }}
範例 /static/blog/images/logo.png /media/articles/covers/photo.jpg

預設的封面圖片是由開發者準備的,所以應該放在靜態檔案目錄中。我們將它放在 blog/static/blog/images/default-cover.jpg,並使用 {% static 'blog/images/default-cover.jpg' %} 標籤來取得 URL。

而使用者上傳的封面圖片則會儲存在媒體檔案目錄中(media/articles/covers/),並使用 .url 屬性來取得 URL。

3. 使用模型方法

更好的做法是在模型中定義一個方法來處理這個邏輯,這樣可以在多個地方重複使用。

修改 blog/models.py

blog/models.py
from django.conf import settings
from django.db import models
from django.templatetags.static import static  # (1)!


class Author(models.Model):
    name = models.CharField(max_length=100)
    email = models.EmailField(unique=True)
    bio = models.TextField(blank=True)
    created_at = models.DateTimeField(auto_now_add=True)

    def __str__(self):
        return self.name


class Tag(models.Model):
    name = models.CharField(max_length=50, unique=True)

    def __str__(self):
        return self.name


class Article(models.Model):
    title = models.CharField(max_length=200)
    content = models.TextField()
    cover_image = models.ImageField(
        upload_to="articles/covers/",
        blank=True,
        null=True,
    )
    created_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
    )
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)
    is_published = models.BooleanField(default=False)

    author = models.ForeignKey(
        Author,
        on_delete=models.CASCADE,
        related_name="articles",
        null=True,
        blank=True,
    )

    tags = models.ManyToManyField(
        Tag,
        related_name="articles",
        blank=True,
    )

    def __str__(self):
        return self.title

    def get_cover_image_url(self):  # (2)!
        if self.cover_image:
            return self.cover_image.url

        return static("blog/images/default-cover.jpg")  # (3)!
  1. 在檔案頂端匯入 static 函式
  2. 新增 get_cover_image_url() 方法來統一處理封面圖片的取得邏輯
  3. 使用 static() 函式來取得靜態檔案的 URL,路徑為 blog/images/default-cover.jpg(對應 blog/static/blog/images/default-cover.jpg

然後在 Template 中就可以這樣使用:

template.html
<img src="{{ article.get_cover_image_url }}" alt="{{ article.title }}">

這樣的好處是:

  • 邏輯集中在一個地方,容易維護
  • Template 更簡潔
  • 如果需要修改預設圖片的路徑,只需要改模型即可
  • 使用 static() 函式可以正確處理 STATIC_URL 設定

檔案驗證與安全性

允許使用者上傳檔案時,安全性是非常重要的考量。我們需要驗證檔案的大小、類型和內容,避免惡意檔案造成安全問題。

檔案大小限制

Django 提供了 FILE_UPLOAD_MAX_MEMORY_SIZEDATA_UPLOAD_MAX_MEMORY_SIZE 設定來限制上傳檔案的大小,但這些是全域設定。如果我們想要針對特定欄位設定大小限制,可以使用自訂驗證器。

建立 blog/validators.py

blog/validators.py
from django.core.exceptions import ValidationError


def validate_image_size(image):  # (1)!
    max_size_mb = 5
    if image.size > max_size_mb * 1024 * 1024:  # (2)!
        error_message = f"圖片大小不得超過 {max_size_mb}MB"
        raise ValidationError(error_message)
  1. 驗證器是一個接受欄位值的函式
  2. image.size 是檔案大小(以 bytes 為單位)

然後在模型中使用這個驗證器,修改 blog/models.py

blog/models.py
from django.conf import settings
from django.db import models
from django.templatetags.static import static

from blog.validators import validate_image_size  # (1)!


class Author(models.Model):
    name = models.CharField(max_length=100)
    email = models.EmailField(unique=True)
    bio = models.TextField(blank=True)
    created_at = models.DateTimeField(auto_now_add=True)

    def __str__(self):
        return self.name


class Tag(models.Model):
    name = models.CharField(max_length=50, unique=True)

    def __str__(self):
        return self.name


class Article(models.Model):
    title = models.CharField(max_length=200)
    content = models.TextField()
    cover_image = models.ImageField(
        upload_to="articles/covers/",
        blank=True,
        null=True,
        validators=[validate_image_size],  # (2)!
    )
    created_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
    )
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)
    is_published = models.BooleanField(default=False)

    author = models.ForeignKey(
        Author,
        on_delete=models.CASCADE,
        related_name="articles",
        null=True,
        blank=True,
    )

    tags = models.ManyToManyField(
        Tag,
        related_name="articles",
        blank=True,
    )

    def __str__(self):
        return self.title

    def get_cover_image_url(self):
        if self.cover_image:
            return self.cover_image.url

        return static("blog/images/default-cover.jpg")
  1. 在檔案頂端匯入驗證器
  2. 將驗證器加入 validators 參數

檔案類型驗證

雖然 ImageField 會自動驗證圖片格式,但我們可以更進一步限制允許的檔案類型。

blog/validators.py 中新增:

blog/validators.py
from pathlib import Path

from django.core.exceptions import ValidationError


def validate_image_size(image):
    max_size_mb = 5
    if image.size > max_size_mb * 1024 * 1024:
        error_message = f"圖片大小不得超過 {max_size_mb}MB"
        raise ValidationError(error_message)


def validate_image_extension(image):
    valid_extensions = [".jpg", ".jpeg", ".png", ".gif"]
    ext = Path(image.name).suffix.lower()

    if ext not in valid_extensions:
        error_message = f"不支援的檔案格式。支援的格式: {', '.join(valid_extensions)}"
        raise ValidationError(error_message)

然後更新模型,在 validators 中加入新的驗證器:

blog/models.py
from django.conf import settings
from django.db import models
from django.templatetags.static import static

from blog.validators import validate_image_extension, validate_image_size


class Author(models.Model):
    name = models.CharField(max_length=100)
    email = models.EmailField(unique=True)
    bio = models.TextField(blank=True)
    created_at = models.DateTimeField(auto_now_add=True)

    def __str__(self):
        return self.name


class Tag(models.Model):
    name = models.CharField(max_length=50, unique=True)

    def __str__(self):
        return self.name


class Article(models.Model):
    title = models.CharField(max_length=200)
    content = models.TextField()
    cover_image = models.ImageField(
        upload_to="articles/covers/",
        blank=True,
        null=True,
        validators=[
            validate_image_size,
            validate_image_extension,
        ],
    )
    created_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
    )
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)
    is_published = models.BooleanField(default=False)

    author = models.ForeignKey(
        Author,
        on_delete=models.CASCADE,
        related_name="articles",
        null=True,
        blank=True,
    )

    tags = models.ManyToManyField(
        Tag,
        related_name="articles",
        blank=True,
    )

    def __str__(self):
        return self.title

    def get_cover_image_url(self):
        if self.cover_image:
            return self.cover_image.url

        return static("blog/images/default-cover.jpg")

圖片驗證

我們還可以驗證圖片的尺寸,例如限制最小或最大寬度和高度。

blog/validators.py 中新增:

blog/validators.py
from pathlib import Path

from django.core.exceptions import ValidationError


def validate_image_size(image):
    max_size_mb = 5
    if image.size > max_size_mb * 1024 * 1024:
        error_message = f"圖片大小不得超過 {max_size_mb}MB"
        raise ValidationError(error_message)


def validate_image_extension(image):
    valid_extensions = [".jpg", ".jpeg", ".png", ".gif"]
    ext = Path(image.name).suffix.lower()

    if ext not in valid_extensions:
        error_message = f"不支援的檔案格式。支援的格式: {', '.join(valid_extensions)}"
        raise ValidationError(error_message)


def validate_image_dimensions(image):
    max_width = 800
    max_height = 600

    if image.width > max_width or image.height > max_height:
        error_message = f"圖片尺寸不符合目標尺寸: {max_width}x{max_height}, 目前尺寸: {image.width}x{image.height}"
        raise ValidationError(error_message)

然後更新模型,在 validators 中加入新的驗證器:

blog/models.py
from django.conf import settings
from django.db import models
from django.templatetags.static import static

from blog.validators import validate_image_dimensions, validate_image_extension, validate_image_size


class Author(models.Model):
    name = models.CharField(max_length=100)
    email = models.EmailField(unique=True)
    bio = models.TextField(blank=True)
    created_at = models.DateTimeField(auto_now_add=True)

    def __str__(self):
        return self.name


class Tag(models.Model):
    name = models.CharField(max_length=50, unique=True)

    def __str__(self):
        return self.name


class Article(models.Model):
    title = models.CharField(max_length=200)
    content = models.TextField()
    cover_image = models.ImageField(
        upload_to="articles/covers/",
        blank=True,
        null=True,
        validators=[
            validate_image_size,
            validate_image_extension,
            validate_image_dimensions,
        ],
    )
    created_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
    )
    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)
    is_published = models.BooleanField(default=False)

    author = models.ForeignKey(
        Author,
        on_delete=models.CASCADE,
        related_name="articles",
        null=True,
        blank=True,
    )

    tags = models.ManyToManyField(
        Tag,
        related_name="articles",
        blank=True,
    )

    def __str__(self):
        return self.title

    def get_cover_image_url(self):
        if self.cover_image:
            return self.cover_image.url

        return static("blog/images/default-cover.jpg")

安全性注意事項

  1. 永遠驗證檔案類型:不要只依賴副檔名,因為使用者可以輕易修改副檔名。ImageField 會實際讀取檔案內容來驗證是否為有效的圖片。

  2. 限制檔案大小:防止使用者上傳過大的檔案消耗伺服器資源。

  3. 儲存在 MEDIA_ROOT 之外:確保上傳的檔案儲存在文件根目錄(Document Root)之外,避免使用者上傳可執行的腳本。

  4. 使用隨機檔名:考慮使用 UUID 等方式重新命名上傳的檔案,避免檔名衝突和路徑遍歷攻擊。

  5. 病毒掃描:在生產環境中,考慮整合病毒掃描服務來檢查上傳的檔案。

最後別忘了執行 makemigrations 與 migrate 套用 Model 的變更

uv run manage.py makemigrations
uv run manage.py migrate

接下來你就可以去測試一下你的驗證是否有效了!

檔案管理

在實務中,當使用者上傳新檔案來替換舊檔案,或是刪除包含檔案的模型實例時,Django 不會自動刪除舊的檔案。這些孤立的檔案會一直佔用磁碟空間。

手動撰寫 signal 來處理檔案刪除雖然可行,但容易出錯且需要處理很多邊界情況。幸好有第三方套件可以自動處理這個問題。

使用 django-cleanup

django-cleanup 是一個輕量級的 Django 套件,可以自動刪除孤立的檔案。它會:

  • 在檔案欄位更新時,自動刪除舊檔案
  • 在模型實例刪除時,自動刪除關聯的檔案
  • 支援所有的 FileFieldImageField

安裝 django-cleanup

使用 uv 安裝:

uv add django-cleanup

設定

core/settings.pyINSTALLED_APPS 中加入 django_cleanup重要:必須放在最後面,這樣才能確保它在其他 app 的 signal 之後執行。

core/settings.py
INSTALLED_APPS = [
    # Django 內建 apps
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
    # 第三方 apps
    "django_bootstrap5",
    "django_extensions",
    "django_filters",
    # 本地 apps
    "practices",
    "blog",
    # 工具 apps (必須放在最後)
    "django_cleanup.apps.CleanupConfig",
]

為什麼要放在最後?

django-cleanup 使用 signal 來監聽模型的變更。將它放在 INSTALLED_APPS 的最後,可以確保它在其他 app 的 signal 處理完畢後才執行,避免潛在的衝突。

就這樣!不需要額外的設定或程式碼,django-cleanup 會自動處理所有檔案的清理工作。

運作方式

django-cleanup 使用 Django 的 signal 系統來監聽模型的變更:

  1. 更新檔案時:當 FileFieldImageField 的值改變時(例如使用者上傳新圖片),會自動刪除舊檔案
  2. 刪除模型時:當模型實例被刪除時,會自動刪除所有關聯的檔案
sequenceDiagram
    participant User as 使用者
    participant Django as Django
    participant Cleanup as django-cleanup
    participant Storage as 檔案系統

    User->>Django: 上傳新圖片
    Django->>Storage: 儲存新圖片
    Django->>Cleanup: 觸發 pre_save signal
    Cleanup->>Storage: 刪除舊圖片
    Django->>User: 回應成功

任務結束

任務完成

恭喜你完成了這個任務!現在你已經學會:

  • 了解 Django 的檔案上傳機制
  • 設定 MEDIA_ROOT 和 MEDIA_URL
  • 使用 FileField 和 ImageField
  • 處理上傳檔案的表單
  • 在模板中顯示上傳的圖片
  • 圖片驗證與安全性
  • 使用 django-cleanup 自動管理檔案