圖片與檔案上傳¶
開始之前¶
任務目標
在這個任務中,你將學習:
- 了解 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 行),補充完整的設定:
# 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)!
STATICFILES_DIRS:開發環境中靜態檔案的來源目錄列表STATIC_ROOT:執行collectstatic時,收集所有靜態檔案的目標目錄(生產環境使用)MEDIA_URL:存取媒體檔案的 URL 前綴MEDIA_ROOT:媒體檔案在伺服器上的儲存路徑
接著我們來建立 STATICFILES_DIRS 設定的資料夾
靜態檔案設定說明
Django 會從以下兩個地方尋找靜態檔案:
STATICFILES_DIRS:專案層級的靜態檔案目錄(如BASE_DIR / "static")- 各 APP 的
static資料夾:Django 會自動尋找每個已安裝 APP 中的static目錄
例如,如果你在 blog app 中建立 blog/static/blog/images/logo.png,就可以在 template 中使用:
APP 靜態檔案的命名空間
注意到我們在 blog/static/ 下又建立了 blog/ 子資料夾嗎?這是為了建立「命名空間」,避免不同 APP 的靜態檔案名稱衝突。
這樣即使多個 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 設定:
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)!
]
- 匯入
static函式(settings已經在檔案開頭匯入了) - 在
DEBUG模式下,加入靜態檔案的 URL 設定 - 在
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 提供了兩種欄位類型來處理檔案上傳:FileField 和 ImageField。
FileField¶
FileField 是用來處理一般檔案上傳的欄位,可以上傳任何類型的檔案。
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)
upload_to參數指定檔案上傳後的儲存路徑(相對於MEDIA_ROOT)
當使用者上傳檔案後,Django 會:
- 將檔案儲存到
MEDIA_ROOT / "documents" / "檔案名稱" - 在資料庫中儲存相對路徑
documents/檔案名稱
ImageField¶
ImageField 是 FileField 的子類別,專門用來處理圖片上傳。它會在上傳時驗證檔案是否為有效的圖片格式。
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)
- 使用
ImageField來處理圖片上傳
ImageField 與 FileField 的差異
ImageField 在上傳時會驗證:
- 檔案是否為有效的圖片格式(PNG, JPEG, GIF 等)
- 圖片是否能被正常開啟和讀取
如果驗證失敗,Django 會拋出例外。此外,ImageField 還提供了 width 和 height 屬性來取得圖片的尺寸。
安裝 Pillow¶
使用 ImageField 需要安裝 Pillow 套件。Pillow 是 Python 的圖片處理函式庫,Django 會使用它來驗證圖片格式、讀取圖片尺寸等。
使用 uv 安裝 Pillow:
為什麼需要 Pillow?
Django 的 ImageField 在上傳時會使用 Pillow 來:
- 驗證檔案是否為有效的圖片格式
- 讀取圖片的尺寸資訊(width 和 height)
- 確保圖片可以被正常開啟和讀取
如果沒有安裝 Pillow,使用 ImageField 時會出現錯誤。
現在讓我們為 Article 模型新增封面圖片欄位。開啟 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
- 新增
cover_image欄位,設定為可選(blank=True, null=True)
upload_to 參數¶
upload_to 參數用來指定檔案的儲存路徑。它可以接受字串、格式化字串或函式。
使用字串¶
當使用字串時,所有上傳的檔案都會儲存在同一個目錄下:
這樣所有文章的封面圖片都會儲存在 MEDIA_ROOT/articles/covers/ 目錄下。
使用格式化字串¶
Django 支援使用格式化字串來動態組織上傳路徑,會根據檔案上傳的日期時間自動建立目錄:
%Y、%m、%d會被替換為年、月、日
支援的格式化選項:
%Y:四位數年份(如2024)%m:兩位數月份(如01、12)%d:兩位數日期(如01、31)%H、%M、%S:時、分、秒
例如,在 2024 年 12 月 27 日上傳的檔案會儲存在 MEDIA_ROOT/articles/covers/2024/12/27/filename.jpg。
使用函式動態設定路徑¶
如果需要更複雜的邏輯,可以使用函式來動態設定上傳路徑。例如,根據使用者 ID 來分類檔案:
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)
upload_to函式接受兩個參數:instance(模型實例)和filename(原始檔案名稱)- 取得建立者的 ID,如果沒有則使用 'anonymous'
- 組合路徑:
articles/users/使用者ID/檔案名稱 - 將函式傳給
upload_to參數(注意不要加括號)
這樣上傳的檔案會儲存在 MEDIA_ROOT/articles/users/1/filename.jpg 這樣的路徑下。
instance 的狀態
使用函式時要注意:
- 新建立的實例:如果是第一次儲存,
instance.pk會是None,某些欄位(如auto_now_add的欄位)可能還沒有值 - 外鍵關聯:確保外鍵欄位已經設定,否則可能會出現
AttributeError - 避免依賴尚未儲存的資料:在函式中盡量使用已經設定的資料(如外鍵)
進階:避免檔名衝突
雖然 Django 有自己處理檔名重複問題,但如果希望自己處理,可以使用 UUID 來生成唯一的檔名:
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 的變更
檔案上傳表單¶
要讓使用者能夠上傳檔案,我們需要在表單、View 和 Template 三個地方做相應的設定。
Form 設定¶
在 Django Form 中處理檔案上傳時,不需要特別的設定,只要確保 Form 包含了對應的欄位即可。
讓我們修改 blog/forms.py,在 ArticleForm 中加入 cover_image 欄位:
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
- 在
fields中加入cover_image欄位來處理封面圖片的上傳
View 處理¶
在 View 中處理檔案上傳時,需要注意以下幾點:
- 在處理 POST 請求時,需要同時傳入
request.FILES - 檔案會透過
request.FILES字典來傳遞
開啟 blog/views.py,修改 article_create 函式來處理檔案上傳:
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})
- 重要:在
article_create中,需要同時傳入request.POST和request.FILES - 重要:在
article_edit中也要加入request.FILES,這樣才能更新封面圖片
不要忘記 request.FILES
如果沒有傳入 request.FILES,檔案上傳會失敗!這是新手常犯的錯誤。
Template 中的檔案上傳¶
在 Template 中,我們需要在 <form> 標籤中加入 enctype="multipart/form-data" 屬性,這樣瀏覽器才會正確地傳送檔案資料。
修改 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",檔案上傳會失敗!
- 沒有設定:瀏覽器只會傳送檔案名稱,不會傳送檔案內容
- 有設定:瀏覽器會正確地將檔案內容編碼並傳送
顯示上傳的檔案¶
上傳檔案後,我們需要在 Template 中顯示這些檔案。
在 Template 中顯示圖片¶
Django 的 FileField 和 ImageField 提供了 .url 屬性,可以取得檔案的 URL。
讓我們修改文章列表和文章詳情頁面來顯示封面圖片。
首先,修改 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>
- 檢查文章是否有封面圖片
- 使用
.url屬性取得圖片的 URL
接著修改 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 %} 來判斷:
{% if article.cover_image %}
<img src="{{ article.cover_image.url }}" alt="{{ article.title }}">
{% else %}
<div class="no-image">沒有封面圖片</div>
{% endif %}
2. 提供預設圖片¶
我們也可以在沒有圖片時顯示預設圖片:
{% 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 %}
- 使用
{% static %}標籤前需要先載入 - 使用
{% 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:
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)!
- 在檔案頂端匯入
static函式 - 新增
get_cover_image_url()方法來統一處理封面圖片的取得邏輯 - 使用
static()函式來取得靜態檔案的 URL,路徑為blog/images/default-cover.jpg(對應blog/static/blog/images/default-cover.jpg)
然後在 Template 中就可以這樣使用:
這樣的好處是:
- 邏輯集中在一個地方,容易維護
- Template 更簡潔
- 如果需要修改預設圖片的路徑,只需要改模型即可
- 使用
static()函式可以正確處理STATIC_URL設定
檔案驗證與安全性¶
允許使用者上傳檔案時,安全性是非常重要的考量。我們需要驗證檔案的大小、類型和內容,避免惡意檔案造成安全問題。
檔案大小限制¶
Django 提供了 FILE_UPLOAD_MAX_MEMORY_SIZE 和 DATA_UPLOAD_MAX_MEMORY_SIZE 設定來限制上傳檔案的大小,但這些是全域設定。如果我們想要針對特定欄位設定大小限制,可以使用自訂驗證器。
建立 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)
- 驗證器是一個接受欄位值的函式
image.size是檔案大小(以 bytes 為單位)
然後在模型中使用這個驗證器,修改 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")
- 在檔案頂端匯入驗證器
- 將驗證器加入
validators參數
檔案類型驗證¶
雖然 ImageField 會自動驗證圖片格式,但我們可以更進一步限制允許的檔案類型。
在 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 中加入新的驗證器:
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 中新增:
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 中加入新的驗證器:
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")
安全性注意事項
-
永遠驗證檔案類型:不要只依賴副檔名,因為使用者可以輕易修改副檔名。
ImageField會實際讀取檔案內容來驗證是否為有效的圖片。 -
限制檔案大小:防止使用者上傳過大的檔案消耗伺服器資源。
-
儲存在 MEDIA_ROOT 之外:確保上傳的檔案儲存在文件根目錄(Document Root)之外,避免使用者上傳可執行的腳本。
-
使用隨機檔名:考慮使用 UUID 等方式重新命名上傳的檔案,避免檔名衝突和路徑遍歷攻擊。
-
病毒掃描:在生產環境中,考慮整合病毒掃描服務來檢查上傳的檔案。
最後別忘了執行 makemigrations 與 migrate 套用 Model 的變更
接下來你就可以去測試一下你的驗證是否有效了!
檔案管理¶
在實務中,當使用者上傳新檔案來替換舊檔案,或是刪除包含檔案的模型實例時,Django 不會自動刪除舊的檔案。這些孤立的檔案會一直佔用磁碟空間。
手動撰寫 signal 來處理檔案刪除雖然可行,但容易出錯且需要處理很多邊界情況。幸好有第三方套件可以自動處理這個問題。
使用 django-cleanup¶
django-cleanup 是一個輕量級的 Django 套件,可以自動刪除孤立的檔案。它會:
- 在檔案欄位更新時,自動刪除舊檔案
- 在模型實例刪除時,自動刪除關聯的檔案
- 支援所有的
FileField和ImageField
安裝 django-cleanup¶
使用 uv 安裝:
設定¶
在 core/settings.py 的 INSTALLED_APPS 中加入 django_cleanup。重要:必須放在最後面,這樣才能確保它在其他 app 的 signal 之後執行。
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 系統來監聽模型的變更:
- 更新檔案時:當
FileField或ImageField的值改變時(例如使用者上傳新圖片),會自動刪除舊檔案 - 刪除模型時:當模型實例被刪除時,會自動刪除所有關聯的檔案
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 自動管理檔案