Django 3網(wǎng)頁(yè)開發(fā)指南第4版 第3章 表單和視圖PART 1
時(shí)間:2023-05-28 01:57:01 | 來源:網(wǎng)站運(yùn)營(yíng)
時(shí)間:2023-05-28 01:57:01 來源:網(wǎng)站運(yùn)營(yíng)
Django 3網(wǎng)頁(yè)開發(fā)指南第4版 第3章 表單和視圖PART 1:本文完整目錄請(qǐng)見 [**Django 3網(wǎng)頁(yè)開發(fā)指南 - 第4版**](Django 3網(wǎng)頁(yè)開發(fā)指南第4版 第1章 Django 3.0入門)
本章包含如下內(nèi)容:
* 使用CRUDL函數(shù)創(chuàng)建應(yīng)用
* 保存模型實(shí)例的作者
* 上傳圖片
* 通過自定義模型創(chuàng)建表單布局
* 通過django-crispy-forms創(chuàng)建表單布局
* 處理formsets
* 過濾對(duì)象列表
* 管理分頁(yè)列表
* 編寫基于類的視圖
* 添加Open Graph和Twitter Card數(shù)據(jù)
* 添加
http://schema.org用詞
* 生成PDF文檔
* 通過Haystack和Whoosh實(shí)現(xiàn)多語言搜索
* 通過Elasticsearch DSL實(shí)現(xiàn)多語言搜索
## 引言
在模型中定義了數(shù)據(jù)庫(kù)結(jié)構(gòu)時(shí),視圖提供了要向用戶顯示內(nèi)容或讓用戶輸入新數(shù)據(jù)及更新數(shù)據(jù)的端點(diǎn)(endpoint)。本章中, 我們集中學(xué)習(xí)管理表單的視圖、列表視圖及向HTML生成替代輸出的視圖。這最簡(jiǎn)化的示例中,URL規(guī)則及模板創(chuàng)建就交給讀者了。
## 技術(shù)要求
要使用本章中的代碼,同時(shí),讀者需要最新的穩(wěn)定版Python、MySQL或PostgreSQL數(shù)據(jù)庫(kù)以及虛擬環(huán)境中創(chuàng)建的Django項(xiàng)目中。部分小節(jié)要求有特定的Python依賴。此外,要生成PDF文件,需要有cairo、pango、gdk-pixbuf及l(fā)ibffi庫(kù)。搜索需要用到Elasticsearch服務(wù)端。更多詳情在相應(yīng)的小節(jié)中會(huì)進(jìn)行討論。
本章中的大部分模板會(huì)使用Bootstrap 4 CSS框架來保持美觀度。
本章中的代碼請(qǐng)見[GitHub倉(cāng)庫(kù)](alanhou/django3-cookbook)的Chapter03目錄。
## 使用CRUDL函數(shù)創(chuàng)建應(yīng)用
在計(jì)算機(jī)科學(xué)領(lǐng)域,CRUDL是Create(創(chuàng)建/增), Read(讀取/查), Update(更新/改), Delete(刪除/刪)和List(列舉)函數(shù)的縮寫。很多具有交互功能的Django項(xiàng)目要求我們實(shí)現(xiàn)所有這些函數(shù)來對(duì)網(wǎng)站進(jìn)行數(shù)據(jù)的管理。本小節(jié)中,我們學(xué)習(xí)如何通過這些基本函數(shù)來創(chuàng)建URL和視圖。
### 準(zhǔn)備工作
我們來創(chuàng)建一個(gè)名為ideas的應(yīng)用并將添加到設(shè)置文件的INSTALLED_APPS中。在該應(yīng)用中創(chuàng)建如下包含帶有翻譯文件的IdeaTranslations模型及Idea模型:
```
# myproject/apps/idea/models.py
import uuid
from django.db import models
from django.urls import reverse
from django.conf import settings
from django.utils.translation import gettext_lazy as _
from myproject.apps.core.model_fields import TranslatedField
from myproject.apps.core.models import (
CreationModificationDateBase, UrlBase
)
RATING_CHOICES = (
(1, "★☆☆☆☆"),
(2, "★★☆☆☆"),
(3, "★★★☆☆"),
(4, "★★★★☆"),
(5, "★★★★★"),
)
class Idea(CreationModificationDateBase, UrlBase):
uuid = models.UUIDField(
primary_key=True, default=uuid.uuid4, editable=False
)
author = models.ForeignKey(
settings.AUTH_USER_MODEL,
verbose_name=_("Author"),
on_delete=models.SET_NULL,
blank=True,
null=True,
related_name="authored_ideas",
)
title = models.CharField(_("Title"), max_length=200)
content = models.TextField(_("Content"))
categories = models.ManyToManyField(
"categories.Category",
verbose_name=_("Categories"),
related_name="category_ideas",
)
rating = models.PositiveIntegerField(
_("Rating"),
choices=RATING_CHOICES,
blank=True,
null=True
)
translated_title = TranslatedField("title")
translated_content = TranslatedField("content")
class Meta:
verbose_name = _("Idea")
verbose_name_plural = _("Ideas")
def __str__(self):
return self.title
def get_url_path(self):
return reverse("ideas:idea_detail", kwargs={"pk": self.pk})
class IdeaTranslations(models.Model):
idea = models.ForeignKey(
Idea,
verbose_name=_("Idea"),
on_delete=models.CASCADE,
related_name="translations",
)
language = models.CharField(_("Language"), max_length=7)
title = models.CharField(_("Title"), max_length=200)
content = models.TextField(_("Content"))
class Meta:
verbose_name = _("Idea Translations")
verbose_name_plural = _("Idea Translations")
ordering = ["language"]
unique_together = [["idea", "language"]]
def __str__(self):
return self.title
```
我們使用了前一章中的一些概念:繼承了模型mixin并使用了一個(gè)模型翻譯表。閱讀*使用模型mixin*及*操作模型翻譯數(shù)據(jù)表*小節(jié)了解更多內(nèi)容。我們將在本章的剩余小節(jié)中使用ideas應(yīng)用及這些模型。
此外,創(chuàng)建一個(gè)同級(jí)categories應(yīng)用并包含Category和CategoryTranslations模型:
```
# myproject/apps/categories/models.py
from django.db import models
from django.utils.translation import gettext_lazy as _
from myproject.apps.core.model_fields import TranslatedField
class Category(models.Model):
title = models.CharField(_("Title"), max_length=200)
translated_title = TranslatedField("title")
class Meta:
verbose_name = _("Category")
verbose_name_plural = _("Categories")
def __str__(self):
return self.title
class CategoryTranslations(models.Model):
category = models.ForeignKey(
Category,
verbose_name=_("Category"),
on_delete=models.CASCADE,
related_name="translations",
)
language = models.CharField(_("Language"), max_length=7)
title = models.CharField(_("Title"), max_length=200)
class Meta:
verbose_name = _("Category Translations")
verbose_name_plural = _("Category Translations")
ordering = ["language"]
unique_together = [["category", "language"]]
def __str__(self):
return self.title
```
### 如何實(shí)現(xiàn)...
Django中的CRUDL功能由表單、視圖和URL規(guī)則所組成。下面進(jìn)行創(chuàng)建:
1. 在ideas應(yīng)用中新增forms.py文件,添加用于對(duì)Idea模型實(shí)例進(jìn)行新增和修改的模型表單:
```
# myprojects/apps/ideas/forms.py
from django import forms
from .models import Idea
class IdeaForm(forms.ModelForm):
class Meta:
model = Idea
fields = "__all__"
```
2. 在ideas應(yīng)用中添加views.py用于新增操作Idea模型的視圖:
```
# myproject/apps/ideas/views.py
from django.contrib.auth.decorators import login_required
from django.shortcuts import render, redirect, get_object_or_404
from django.views.generic import ListView, DetailView
from .forms import IdeaForm
from .models import Idea
class IdeaList(ListView):
model = Idea
class IdeaDetail(DetailView):
model = Idea
context_object_name = "idea"
@login_required
def add_or_change_idea(request, pk=None):
idea = None
if pk:
idea = get_object_or_404(Idea, pk=pk)
if request.method == "POST":
form = IdeaForm(
data=request.POST,
files=request.FILES,
instance=idea
)
if form.is_valid():
idea = form.save()
return redirect("ideas:idea_detail", pk=idea.pk)
else:
form = IdeaForm(instance=idea)
context = {"idea": idea, "form": form}
return render(request, "ideas/idea_form.html", context)
@login_required
def delete_idea(request, pk):
idea = get_object_or_404(Idea, pk=pk)
if request.method == "POST":
idea.delete()
return redirect("ideas:idea_list")
context = {"idea": idea}
return render(request, "ideas/idea_deleting_confirmation.html", context)
```
3. 在ideas應(yīng)用創(chuàng)建urls.py文件并添加URL規(guī)則:
```
# myproject/apps/ideas/urls.py
from django.urls import path
from .views import (
IdeaList,
IdeaDetail,
add_or_change_idea,
delete_idea,
)
urlpatterns = [
path("", IdeaList.as_view(), name="idea_list"),
path("add/", add_or_change_idea, name="add_idea"),
path("<uuid:pk>/", IdeaDetail.as_view(), name="idea_detail"),
path("<uuid:pk>/change/", add_or_change_idea, name="change_idea"),
path("<uuid:pk>/delete/", delete_idea, name="delete_idea"),
]
```
4. 現(xiàn)在我們把這些URL規(guī)則插入到項(xiàng)目的URL配置中。我們還會(huì)包含Django社區(qū)的auth應(yīng)用中的賬戶URL規(guī)則,這樣@login_required裝飾器就可以正常運(yùn)行了:
```
# myproject/urls.py
from django.contrib import admin
from django.conf.urls.i18n import i18n_patterns
from django.urls import include, path
from django.conf import settings
from django.conf.urls.static import static
from django.shortcuts import redirect
urlpatterns = i18n_patterns(
path("", lambda request: redirect("ideas:idea_list")),
path("admin/", admin.site.urls),
path("accounts/", include("django.contrib.auth.urls")),
path("ideas/", include(("myproject.apps.ideas.urls", "ideas"), namespace="ideas")),
)
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
urlpatterns += static("/media/", document_root=settings.MEDIA_ROOT)
```
5. 現(xiàn)在可以創(chuàng)建如下模板了:
* 包含登錄表單的registration/login.html
* 包含對(duì)ideas進(jìn)行列舉的ideas/idea_list.html
* 有關(guān)單個(gè)idea詳情的ideas/idea_detail.html
* 帶有添加或修改 idea 表單的ideas/idea_form.html
* 包含確認(rèn)刪除 idea 空表單的ideas/idea_deleting_confirmation.html
在模板中,可以通過如下命名空間和path名稱來調(diào)用ideas應(yīng)用的URL:
```
{% load i18n %}
<a href="{% url 'ideas:change_idea' pk=idea.pk %}">{% trans "Change this idea" %}</a>
<a href="{% url 'ideas:add_idea' %}">{% trans "Add idea" %}</a>
```
> ??如果碰到問題或是希望節(jié)省時(shí)間,可以查看本書代碼文件中的相應(yīng)模板,地址為
https://github.com/PacktPublishing/Django-3-Web-Development-Cookbook-Fourth-Edition/tree/master/ch03/myproject_virtualenv/src/django-myproject/myproject/templates/ideas### 實(shí)現(xiàn)原理...
本例中,我們使用UUID字段作為Idea模型的主鍵。借助這一ID,每個(gè)idea的唯一URL都是無法靠猜來知道的。另一種方式是對(duì)URL使用slug字段,但這時(shí)要確保會(huì)生成slug且在整個(gè)網(wǎng)站中是唯一的。
> 出于安全考慮不推薦對(duì)URL使用默認(rèn)的遞增ID:那樣用戶就能夠推算出數(shù)據(jù)庫(kù)中有多少條記錄并可以嘗試訪問他們可能沒有權(quán)限訪問的前一條或后一條記錄。
在我們示例中,我們使用通用的視圖類來列出并讀取idea以及視圖函數(shù)來增、改、刪這些idea。在數(shù)據(jù)庫(kù)中更改記錄的視圖通過@login_required裝飾器要求為認(rèn)證用戶。使用視圖類或針對(duì)所有的CRUDL函數(shù)的視圖函數(shù)都沒有問題。
在成功新增或修改idea之后,這些用戶會(huì)被重定向到詳情視圖。在刪除idea之后,用戶會(huì)被重定向到列表視圖。
### 擴(kuò)展知識(shí)...
此外可以使用Django消息框架來在成功添加、修改或刪除時(shí)在頁(yè)面頂部顯示成功消息。
可以在[官方文檔](The messages framework)中閱讀相關(guān)內(nèi)容。
### 相關(guān)內(nèi)容
* [**第2章 模型和數(shù)據(jù)庫(kù)結(jié)構(gòu)**](
https://alanhou.org/models-database-structure/)中*使用模型mixin*一節(jié)
* [**第2章 模型和數(shù)據(jù)庫(kù)結(jié)構(gòu)**](Django 3網(wǎng)頁(yè)開發(fā)指南第4版 第2章 模型和數(shù)據(jù)庫(kù)結(jié)構(gòu))中*操作模型翻譯數(shù)據(jù)表*一節(jié)
* *保存模型實(shí)例的作者*一節(jié)
* [**第4章 模板和JavaScript**](Django 3網(wǎng)頁(yè)開發(fā)指南第4版 第4章 模板和JavaScript)中*安排base.html模板*一節(jié)
## 保存模型實(shí)例的作者
Django的每個(gè)視圖的第一個(gè)參數(shù)是HttpRequest對(duì)象,按照慣例名稱為request。它包含有關(guān)由瀏覽器或其它客戶端發(fā)請(qǐng)求的元數(shù)據(jù),包含當(dāng)前語言碼、用戶數(shù)據(jù)、cookie和session數(shù)據(jù)。默認(rèn),視圖使用表單來接受GET或POST數(shù)據(jù)、文件、初始數(shù)據(jù)及其它參數(shù);但是它們不是默認(rèn)就可以訪問HttpRequest對(duì)象。在某些情況下,額外將HttpRequest傳遞給表單會(huì)很用,尤其是在想要根據(jù)其它請(qǐng)求數(shù)據(jù)或在表單中處理當(dāng)前用戶或IP的保存時(shí)過濾掉表單字段的選項(xiàng)時(shí)。
在本節(jié)中,我們將學(xué)習(xí)表單的示例,其中可以添加或修改idea,并將當(dāng)前用戶保存為作者。
### 準(zhǔn)備工作
我們將在前一小節(jié)示例的基礎(chǔ)上進(jìn)行演示。
### 如何實(shí)現(xiàn)...
要完成本小節(jié),執(zhí)行如下兩步:
1. 修改IdeaForm模型如下:
```
# myprojects/apps/ideas/forms.py
from django import forms
from .models import Idea
class IdeaForm(forms.ModelForm):
class Meta:
model = Idea
exclude = ["author"]
def __init__(self, request, *args, **kwargs):
self.request = request
super().__init__(*args, **kwargs)
def save(self, commit=True):
instance = super().save(commit=False)
instance.author = self.request.user
if commit:
instance.save()
self.save_m2m()
return instance
```
2. 修改視圖來添加或修改idea:
```
# myproject/apps/ideas/views.py
from django.contrib.auth.decorators import login_required
from django.shortcuts import render, redirect, get_object_or_404
from .forms import IdeaForm
from .models import Idea
@login_required
def add_or_change_idea(request, pk=None):
idea = None
if pk:
idea = get_object_or_404(Idea, pk=pk)
if request.method == "POST":
form = IdeaForm(request, data=request.POST, files=request.FILES, instance=idea)
if form.is_valid():
idea = form.save()
return redirect("ideas:idea_detail", pk=idea.pk)
else:
form = IdeaForm(request, instance=idea)
context = {"idea": idea, "form": form}
return render(request, "ideas/idea_form.html", context)
```
### 實(shí)現(xiàn)原理...
我們來看下這個(gè)表單。首先,我們從表單中排除了author字段,因?yàn)橄M褂贸绦騺磉M(jìn)行處理。我們重寫了__init__()方法來接收HttpRequest作為第一個(gè)參數(shù)并在表單中進(jìn)行存儲(chǔ)。模型表單的save()方法處理模型的存儲(chǔ)。commit參數(shù)告訴模型表單立即存儲(chǔ)實(shí)例或者是創(chuàng)建并調(diào)用實(shí)例,但暫不保存。在本例中,我們獲取了實(shí)例但不進(jìn)行保存,然后通過當(dāng)前用戶為author賦值。最后在commit為True時(shí)我們保存了該實(shí)例。我們會(huì)動(dòng)態(tài)調(diào)用表單所添加的save_m2m() 方法來保存多對(duì)多關(guān)聯(lián),如categories。
在視圖中,我們只對(duì)表單傳遞request變量作為每一個(gè)參數(shù)。
### 相關(guān)內(nèi)容
* *使用CRUDL函數(shù)創(chuàng)建應(yīng)用*一節(jié)
* *上傳圖片*一節(jié)
## 上傳圖片
在本節(jié)中,我們將了解處理圖片上傳的最簡(jiǎn)單方式。我們會(huì)對(duì)Idea模型添加一個(gè)picture字段,并為不同用途創(chuàng)建不同大小版本的圖片。
### 準(zhǔn)備工作
對(duì)于具有版本的圖片,我們需要用到Pillow和django-imagekit庫(kù)。下面通過pip來在虛擬環(huán)境中進(jìn)行安裝(并在requirements/_base.txt中進(jìn)行添加):
```
(env)$ pip install Pillow
(env)$ pip install django-imagekit==4.0.2
```
然后在設(shè)置的INSTALLED_APPS添加imagekit。
### 如何實(shí)現(xiàn)...
執(zhí)行如下步驟完成本小節(jié)中的開發(fā):
1. 修改Idea模型,添加一個(gè)picture字段以及各圖片版本規(guī)格:
```
# myproject/apps/ideas/models.py import contextlib
import os
from imagekit.models import ImageSpecField
from pilkit.processors import ResizeToFill
from django.db import models
from django.utils.translation import gettext_lazy as _
from django.utils.timezone import now as timezone_now
from myproject.apps.core.models import (
CreationModificationDateBase,
UrlBase
)
def upload_to(instance, filename):
now = timezone_now()
base, extension = os.path.splitext(filename)
extension = extension.lower()
return f"ideas/{now:%Y/%m}/{instance.pk}{extension}"
class Idea(CreationModificationDateBase, UrlBase): # attributes and fields...
picture = models.ImageField(
_("Picture"), upload_to=upload_to
)
picture_social = ImageSpecField(
source="picture",
processors=[ResizeToFill(1024, 512)],
format="JPEG",
options={"quality": 100},
)
picture_large = ImageSpecField(
source="picture",
processors=[ResizeToFill(800, 400)],
format="PNG"
)
picture_thumbnail = ImageSpecField(
source="picture",
processors=[ResizeToFill(728, 250)],
format="PNG"
)
# other fields, properties, and methods...
def delete(self, *args, **kwargs):
from django.core.files.storage import default_storage
if self.picture:
with contextlib.suppress(FileNotFoundError):
default_storage.delete(
self.picture_social.path
)
default_storage.delete(
self.picture_large.path
)
default_storage.delete(
self.picture_thumbnail.path
)
self.picture.delete()
super().delete(*args, **kwargs)
```
2. 和前面小節(jié)中一樣,在forms.py中為Idea模型創(chuàng)建模型表單IdeaForm。
3. 在添加或修改idea的視圖中,確保在表單中在request.POST之后post請(qǐng)求還提交request.FILES:
```
# myproject/apps/ideas/views.py
from django.contrib.auth.decorators import login_required
from django.shortcuts import (render, redirect, get_object_or_404)
from django.conf import settings
from .forms import IdeaForm
from .models import Idea
@login_required
def add_or_change_idea(request, pk=None):
idea = None
if pk:
idea = get_object_or_404(Idea, pk=pk)
if request.method == "POST":
form = IdeaForm( request,
data=request.POST,
files=request.FILES,
instance=idea,
)
if form.is_valid():
idea = form.save()
return redirect("ideas:idea_detail", pk=idea.pk)
else:
form = IdeaForm(request, instance=idea)
context = {"idea": idea, "form": form}
return render(request, "ideas/idea_form.html", context)
```
4. 在模板中,記得為multipart/form- data設(shè)置編碼類型,如下:
```
<form action="{{ request.path }}" method="post"
enctype="multipart/form-data">
{% csrf_token %}
{{ form.as_p }}
<button type="submit">{% trans "Save" %}</button>
</form>
```
> ??如果像*通過django-crispy-forms創(chuàng)建表單布局*一節(jié)中所描述那樣使用django-crispy-form,則會(huì)在表單中自動(dòng)添加enctype屬性。
### 實(shí)現(xiàn)原理...
Django模型表單是動(dòng)態(tài)地由模型進(jìn)行創(chuàng)建的。它們提供來自模型的指定字段,因此無需在表單中手動(dòng)重新定義這些字段。在前例中,我們?yōu)镮dea模型創(chuàng)建了一個(gè)模型表單。在保存表單時(shí),表單會(huì)知道在數(shù)據(jù)庫(kù)中如何保存每個(gè)字段、如何上傳文件并在media目錄中進(jìn)行保存。
本例中的upload_to() 函數(shù)用于將圖片保存到指定目錄并重新定義其名稱,這樣不會(huì)與其它模型實(shí)例中的文件名相沖突。每個(gè)文件保存的路徑類似ideas/2020/01/0422c6fe- b725-4576-8703-e2a9d9270986.jpg,其中包含上傳的年、月以及Idea實(shí)例的主鍵。
> 一些文件系統(tǒng)(如FAT32和NTFS)對(duì)每個(gè)目錄中的文件存在上限;因此按照上傳日期、字母或其它條件分割到不同目錄是一種好實(shí)踐。
我們使用django-imagekit中的ImageSpecField來創(chuàng)建3種圖片規(guī)格:
* picture_social用于社交分享
* picture_large用于詳情視圖
* picture_thumbnail用于列表視圖
圖片規(guī)格在數(shù)據(jù)庫(kù)中不進(jìn)行關(guān)聯(lián),而只是在CACHE/images/ideas/2020/01/0422c6fe-b725-4576-8703- e2a9d9270986/這樣的文件路徑中按默認(rèn)文件存儲(chǔ)進(jìn)行保存。
在模板中,可以按如下使用原始或指定圖片版本:
```
<img src="{{ idea.picture.url }}" alt="" />
<img src="{{ idea.picture_large.url }}" alt="" />
```
在Idea模型定義最后,我們重寫了delete()方法來在刪除Idea實(shí)例本身之前從磁盤上刪除各版本圖片及圖片本身。
### 相關(guān)內(nèi)容
* *通過django-crispy-forms創(chuàng)建表單布局*一節(jié)
* [**第4章 模板和JavaScript**](Django 3網(wǎng)頁(yè)開發(fā)指南第4版 第4章 模板和JavaScript)中的*編排base.html模板*一節(jié)
* [**第4章 模板和JavaScript**](Django 3網(wǎng)頁(yè)開發(fā)指南第4版 第4章 模板和JavaScript)中的*使用響應(yīng)式圖片*一節(jié)
## 通過自定義模型創(chuàng)建表單布局
在Django的早前版本中,所有表單的渲染都獨(dú)立放在Python代碼中處理,但從Django 1.11開始,就引入了基于模板的表單組件渲染。在本小節(jié)中,我們將學(xué)習(xí)如何對(duì)表單組件使用自定義模板。我們會(huì)使用Django后臺(tái)表單來講解自定義組件模板如何提升字段的易用性。
### 準(zhǔn)備工作
我們先創(chuàng)建Idea模型的默認(rèn)后臺(tái)管理并添加翻譯:
```
# myproject/apps/ideas/admin.py
from django import forms
from django.contrib import admin
from django.utils.translation import gettext_lazy as _
from myproject.apps.core.admin import LanguageChoicesForm
from .models import Idea, IdeaTranslations
class IdeaTranslationsForm(LanguageChoicesForm):
class Meta:
model = IdeaTranslations
fields = "__all__"
class IdeaTranslationsInline(admin.StackedInline):
form = IdeaTranslationsForm
model = IdeaTranslations
extra = 0
@admin.register(Idea)
class IdeaAdmin(admin.ModelAdmin):
inlines = [IdeaTranslationsInline]
fieldsets = [
(_("Author and Category"),
{"fields": ["author", "categories"]}),
(_("Title and Content"),
{"fields": ["title", "content", "picture"]}),
(_("Ratings"),
{"fields": ["rating"]}),
]
```
此時(shí)如訪問ideas的后臺(tái)表單,界面類似下面這樣:

### 如何實(shí)現(xiàn)...
學(xué)習(xí)本小節(jié),需執(zhí)行如下步驟:
1. 通過將django.forms添加到INSTALLED_APPS中、在模板配置中將APP_DIRS標(biāo)記設(shè)置為True并使用TemplatesSetting表單渲染器來確保模板系統(tǒng)能夠發(fā)現(xiàn)自定義模板:
```
# myproject/settings/_base.py
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"django.forms",
# other apps...
]
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [os.path.join(BASE_DIR, "myproject", "templates")],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
"django.template.context_processors.media",
"django.template.context_processors.static",
"myproject.apps.core.context_processors .website_url",
]
},
}
]
FORM_RENDERER = "django.forms.renderers.TemplatesSetting"
```
2. 編輯admin.py文件如下:
```
# myproject/apps/ideas/admin.py
from django import forms
from django.contrib import admin
from django.utils.translation import gettext_lazy as _
from myproject.apps.core.admin import LanguageChoicesForm
from myproject.apps.categories.models import Category
from .models import Idea, IdeaTranslations
class IdeaTranslationsForm(LanguageChoicesForm):
class Meta:
model = IdeaTranslations
fields = "__all__"
class IdeaTranslationsInline(admin.StackedInline):
form = IdeaTranslationsForm
model = IdeaTranslations
extra = 0
class IdeaForm(forms.ModelForm):
categories = forms.ModelMultipleChoiceField(
label=_("Categories"),
queryset=Category.objects.all(),
widget=forms.CheckboxSelectMultiple(),
required=True,
)
class Meta:
model = Idea
fields = "__all__"
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields[
"picture"
].widget.template_name = "core/widgets/image.html"
@admin.register(Idea)
class IdeaAdmin(admin.ModelAdmin):
form = IdeaForm
inlines = [IdeaTranslationsInline]
fieldsets = [
(_("Author and Category"),
{"fields": ["author", "categories"]}),
(_("Title and Content"), "picture"]}),
(_("Ratings"), {"fields": ["rating"]}),
]
```
3. 最后,為picture字段創(chuàng)建一個(gè)模板:
```
{# core/widgets/image.html #}
{% load i18n %}
<div style="margin-left: 160px; padding-left: 10px;">
{% if widget.is_initial %}
<a href="{{ widget.value.url }}">
<img src="{{ widget.value.url }}" width="624" height="auto" alt="" />
</a>
{% if not widget.required %}<br />
{{ widget.clear_checkbox_label }}:
<input type="checkbox" name="{{ widget.checkbox_name }}" id="{{ widget.checkbox_id }}">
{% endif %}<br />
{{ widget.input_text }}:
{% endif %}
<input type="{{ widget.type }}" name="{{ widget.name }}"{% include "django/forms/widgets/attrs.html" %}>
</div>
<div class="help">
{% trans "Available formats are JPG, GIF, and PNG." %}
{% trans "Minimal size is 800 x 800 px." %}
</div>
```
### 實(shí)現(xiàn)原理...
此時(shí)再訪問ideas的后臺(tái)界面,效果如下:

這里有兩處變動(dòng):
* 分類選項(xiàng)此時(shí)使用帶有多個(gè)復(fù)選框的組件
* 圖片字段通過指定的模板進(jìn)行了渲染,使用所指定文件類型和尺寸顯示著圖片預(yù)覽和幫助文件。
我們?cè)谶@里所做的是,重寫idea模型表單、修改分類組件及圖片字段的模板。
Django中默認(rèn)的表單渲染器是django.forms.renderers.DjangoTemplates,它僅在應(yīng)用目錄中搜索模板。我們將其修改為django.forms.renderers.TemplatesSetting讓它同時(shí)還在DIRS路徑中進(jìn)行查找。
### 相關(guān)內(nèi)容
* [**第2章 模型和數(shù)據(jù)庫(kù)結(jié)構(gòu)**](Django 3網(wǎng)頁(yè)開發(fā)指南第4版 第2章 模型和數(shù)據(jù)庫(kù)結(jié)構(gòu))中*操作模型翻譯數(shù)據(jù)表*一節(jié)
* *上傳圖片*一節(jié)
* *通過django-crispy-forms創(chuàng)建表單布局*一節(jié)
## 通過django-crispy-forms創(chuàng)建表單布局
Django應(yīng)用django-crispy-forms讓我們可以使用如下一種CSS框架構(gòu)建、自定義及復(fù)用表單:Uni-Form、Bootstrap 3、Bootstrap 4或Foundation。django-crispy-forms的使用與Django自帶的fieldsets有些類似;但它更為高級(jí)、定制化更強(qiáng)??梢栽赑ython代碼中定義表單布局而無需擔(dān)心每個(gè)字段在HTML中如何展示。此外,如果需要添加指定的HTML屬性或標(biāo)簽,也可以輕松實(shí)現(xiàn)。django-crispy-forms所使用的所有標(biāo)記位于templates內(nèi),可在需要時(shí)進(jìn)行重寫。
本小節(jié)中,我們將使用用于開發(fā)響應(yīng)式、mobile-first網(wǎng)頁(yè)項(xiàng)目的流行前臺(tái)框架Bootstrap 4,來為前臺(tái)表單創(chuàng)建漂亮的布局,用于添加或編輯ideas。
### 準(zhǔn)備工作
首先我們使用在本章中創(chuàng)建的ideas應(yīng)用。接著逐一執(zhí)行如下步驟:
1. 記得為網(wǎng)站創(chuàng)建一個(gè)base.html 模板。更多詳情參見[**第4章 模板和JavaScript**](Django 3網(wǎng)頁(yè)開發(fā)指南第4版 第4章 模板和JavaScript)中安排base.html模板一節(jié)。
2. 根據(jù)
https://getbootstrap.com/docs/4.3/getting-started/introduction/將Bootstrap 4前端框架中的CSS和JS文件集成到base.html模板中。
3. 通過pip在虛擬環(huán)境中安裝django-crispy-forms(并將其添加到requirements/_base.txt中):
```
(env)$ pip install django-crispy-forms
```
4. 在設(shè)置的INSTALLED_APPS中添加crispy_forms,然后設(shè)置bootstrap4為項(xiàng)目中所使用的模板包:
```
# myproject/settings/_base.py
INSTALLED_APPS = (
# ...
"crispy_forms",
"ideas",
)
# ...
CRISPY_TEMPLATE_PACK = "bootstrap4"
```
### 如何實(shí)現(xiàn)...
按照如下步驟操作:
1. 修改ideas的模型表單:
```
# myproject/apps/ideas/forms.py
from django import forms
from django.utils.translation import ugettext_lazy as _
from django.conf import settings
from django.db import models
from crispy_forms import bootstrap, helper, layout
from .models import Idea
class IdeaForm(forms.ModelForm):
class Meta:
model = Idea
exclude = ["author"]
def __init__(self, request, *args, **kwargs):
self.request = request
super().__init__(*args, **kwargs)
self.fields["categories"].widget = forms.CheckboxSelectMultiple()
title_field = layout.Field(
"title", css_class="input-block-level"
)
content_field = layout.Field(
"content", css_class="input-block-level", rows="3"
)
main_fieldset = layout.Fieldset(
_("Main data"), title_field, content_field
)
picture_field = layout.Field(
"picture", css_class="input-block-level"
)
format_html = layout.HTML(
"""{% include "ideas/includes/picture_guidelines.html" %}"""
)
picture_fieldset = layout.Fieldset(
_("Picture"),
picture_field,
format_html,
title=_("Image upload"),
css_id="picture_fieldset",
)
categories_field = layout.Field(
"categories", css_class="input-block-level"
)
categories_fieldset = layout.Fieldset(
_("Categories"), categories_field,
css_id="categories_fieldset"
)
submit_button = layout.Submit("save", _("Save"))
actions = bootstrap.FormActions(submit_button)
self.helper = helper.FormHelper()
self.helper.form_action = self.request.path
self.helper.form_method = "POST"
self.helper.layout = layout.Layout(
main_fieldset,
picture_fieldset,
categories_fieldset,
actions,
)
def save(self, commit=True):
instance = super().save(commit=False)
instance.author = self.request.user
if commit:
instance.save()
self.save_m2m()
return instance
```
2. 然后通過如下內(nèi)容創(chuàng)建picture_guidelines.html模板:
```
{# ideas/includes/picture_guidelines.html #}
{% load i18n %}
<p class="form-text text-muted">
{% trans "Available formats are JPG, GIF, and PNG." %}
{% trans "Minimal size is 800 × 800 px." %}
</p>
```
3. 最后更新ideas表單的模板:
```
{# ideas/idea_form.html #}
{% extends "base.html" %}
{% load i18n crispy_forms_tags static %}
{% block content %}
<a href="{% url "ideas:idea_list" %}">{% trans "List of ideas" %}</a>
<h1>
{% if idea %}
{% blocktrans trimmed with title=idea.translated_title %}
Change Idea "{{ title }}
{% endblocktrans %}
{% else %}
{% trans "Add Idea" %}
{% endif %}
</h1>
{% crispy form %}
{% endblock %}
```
### 實(shí)現(xiàn)原理...
在ideas的模型表單中,我們創(chuàng)建了一個(gè)表單幫助類,布局由主字段集、圖片字段集、分類字段集和提交按鈕所組成。每個(gè)字段集由不同字段組成。每個(gè)字段集、字段或按鈕可以帶有其它參數(shù),成為字段的屬性,如rows="3"或placeholder=_("Please enter a title")。對(duì)于HTML類和id屬性,有特定的參數(shù)css_class和css_id。
idea表單的頁(yè)面類似下面這樣:

和前面小節(jié)類似,我們修改了目錄字段的組件并為圖片字段添加了額外的幫助文本。
### 擴(kuò)展知識(shí)...
前例對(duì)于基礎(chǔ)使用已經(jīng)足夠了。但如果需要在表單中設(shè)置指定標(biāo)記,則仍需重寫并修改django-crispy-forms應(yīng)用的模板,因?yàn)樵赑ython文件中沒有硬編碼的標(biāo)記,而是所有生成的標(biāo)記均通過模板進(jìn)行渲染。只需將django-crispy-forms中的模板拷貝到項(xiàng)目模板目錄中并按需修改。
### 相關(guān)內(nèi)容
* *使用CRUDL函數(shù)創(chuàng)建應(yīng)用*一節(jié)
* *通過自定義模型創(chuàng)建表單布局*一節(jié)
* 通過django-crispy-forms創(chuàng)建表單布局
* *過濾對(duì)象列表*一節(jié)
* *管理分頁(yè)列表*一節(jié)
* *編寫基于類的視圖*一節(jié)
* [**第4章 模板和JavaScript**](Django 3網(wǎng)頁(yè)開發(fā)指南第4版 第4章 模板和JavaScript)中*安排base.html模板*一節(jié)
## 處理formsets
除了常規(guī)表單或模型表單外,Django還有一個(gè)表單集的概念。這些是允許一次性創(chuàng)建或修改多個(gè)實(shí)例的同一類型表單的集合。Django表單集可通過JavaScript豐富功能,它讓我們可以對(duì)頁(yè)面動(dòng)態(tài)添加表單集。這也本小節(jié)要討論的。我們會(huì)擴(kuò)展ideas表單來允許對(duì)同一頁(yè)面添加不同語言的翻譯。
### 準(zhǔn)備工作
我們將繼續(xù)使用前一小節(jié)*通過django-crispy-forms創(chuàng)建表單布局*中的IdeaForm。
### 如何實(shí)現(xiàn)...
按照如下步驟:
1. 修改IdeaForm的表單布局:
```
# myproject/apps/ideas/forms.py
from django import forms
from django.utils.translation import ugettext_lazy as _
from django.conf import settings
from django.db import models
from crispy_forms import bootstrap, helper, layout
from .models import Idea, IdeaTranslations
class IdeaForm(forms.ModelForm):
class Meta:
model = Idea
exclude = ["author"]
def __init__(self, request, *args, **kwargs):
self.request = request
super().__init__(*args, **kwargs)
self.fields["categories"].widget = forms.CheckboxSelectMultiple()
title_field = layout.Field(
"title", css_class="input-block-level"
)
content_field = layout.Field(
"content", css_class="input-block-level", rows="3"
)
main_fieldset = layout.Fieldset(
_("Main data"), title_field, content_field
)
picture_field = layout.Field(
"picture", css_class="input-block-level"
)
format_html = layout.HTML(
"""{% include "ideas/includes/picture_guidelines.html" %}"""
)
picture_fieldset = layout.Fieldset(
_("Picture"),
picture_field,
format_html,
title=_("Image upload"),
css_id="picture_fieldset",
)
categories_field = layout.Field(
"categories", css_class="input-block-level"
)
categories_fieldset = layout.Fieldset(
_("Categories"), categories_field,
css_id="categories_fieldset" )
inline_translations = layout.HTML(
"""{% include "ideas/forms/translations.html" %}"""
)
submit_button = layout.Submit("save", _("Save"))
actions = bootstrap.FormActions(submit_button)
self.helper = helper.FormHelper()
self.helper.form_action = self.request.path
self.helper.form_method = "POST"
self.helper.layout = layout.Layout(
main_fieldset,
inline_translations,
picture_fieldset,
categories_fieldset,
actions,
)
def save(self, commit=True):
instance = super().save(commit=False)
instance.author = self.request.user
if commit:
instance.save()
self.save_m2m()
return instance
```
2. 然后,同一文件的最后添加IdeaTranslationsForm:
```
class IdeaTranslationsForm(forms.ModelForm):
language = forms.ChoiceField(
label=_("Language"),
choices=settings.LANGUAGES_EXCEPT_THE_DEFAULT,
required=True,
)
class Meta:
model = IdeaTranslations
exclude = ["idea"]
def __init__(self, request, *args, **kwargs):
self.request = request
super().__init__(*args, **kwargs)
id_field = layout.Field("id")
language_field = layout.Field(
"language", css_class="input-block-level"
)
title_field = layout.Field(
"title", css_class="input-block-level"
)
content_field = layout.Field(
"content", css_class="input-block-level", rows="3"
)
delete_field = layout.Field("DELETE")
main_fieldset = layout.Fieldset(
_("Main data"),
id_field,
language_field,
title_field,
content_field,
delete_field,
)
self.helper = helper.FormHelper()
self.helper.form_tag = False
self.helper.disable_csrf = True
self.helper.layout = layout.Layout(main_fieldset)
```
3. 修改視圖來添加或修改ideas,如下:
```
# myproject/apps/ideas/views.py
from django.contrib.auth.decorators import login_required
from django.shortcuts import render, redirect, get_object_or_404
from django.forms import modelformset_factory
from django.conf import settings
from .forms import IdeaForm, IdeaTranslationsForm
from .models import Idea, IdeaTranslations
@login_required
def add_or_change_idea(request, pk=None):
idea = None
if pk:
idea = get_object_or_404(Idea, pk=pk)
IdeaTranslationsFormSet = modelformset_factory(
IdeaTranslations, form=IdeaTranslationsForm,
extra=0, can_delete=True
)
if request.method == "POST":
form = IdeaForm(request, data=request.POST,
files=request.FILES, instance=idea)
translations_formset = IdeaTranslationsFormSet(
queryset=IdeaTranslations.objects.filter(idea=idea),
data=request.POST,
files=request.FILES,
prefix="translations",
form_kwargs={"request": request},
)
if form.is_valid() and translations_formset.is_valid():
idea = form.save()
translations = translations_formset.save(
commit=False
)
for translation in translations:
translation.idea = idea
translation.save()
translations_formset.save_m2m()
for translation in translations_formset.deleted_objects:
translation.delete()
return redirect("ideas:idea_detail", pk=idea.pk)
else:
form = IdeaForm(request, instance=idea)
translations_formset = IdeaTranslationsFormSet(
queryset=IdeaTranslations.objects.filter(idea=idea),
prefix="translations",
form_kwargs={"request": request},
)
context = {
"idea": idea,
"form": form,
"translations_formset": translations_formset
}
return render(request, "ideas/idea_form.html", context)
```
4. 然后,編輯idea_form.html模板并在最后添加對(duì)inlines.js腳本的引用:
```
{# ideas/idea_form.html #}
{% extends "base.html" %}
{% load i18n crispy_forms_tags static %}
{% block content %}
<a href="{% url "ideas:idea_list" %}">{% trans "List of ideas" %}</a>
<h1>
{% if idea %}
{% blocktrans trimmed with title=idea.translated_title %}
Change Idea "{{ title }}"
{% endblocktrans %}
{% else %}
{% trans "Add Idea" %}
{% endif %}
</h1>
{% crispy form %}
{% endblock %}
{% block js %}
<script src="{% static 'site/js/inlines.js' %}"></script>
{% endblock %}
```
5. 為翻譯表單集創(chuàng)建模板:
```
{# ideas/forms/translations.html #}
{% load i18n crispy_forms_tags %}
<section id="translations_section" class="formset my-3">
{{ translations_formset.management_form }}
<h3>{% trans "Translations" %}</h3>
<div class="formset-forms">
{% for formset_form in translations_formset %}
<div class="formset-form">
{% crispy formset_form %}
</div>
{% endfor %}
</div>
<button type="button" class="btn btn-primary btn-sm add-inline-form">
{% trans "Add translations to another language" %}
</button>
<div class="empty-form d-none">
{% crispy translations_formset.empty_form %}
</div>
</section>
```
6. 最后,添加JavaScript來操作表單集:
```
/* site/js/inlines.js */
window.WIDGET_INIT_REGISTER = window.WIDGET_INIT_REGISTER || [];
$(function () {
function reinit_widgets($formset_form) {
$(window.WIDGET_INIT_REGISTER).each(function (index, func)
{
func($formset_form);
});
}
function set_index_for_fields($formset_form, index) {
$formset_form.find(':input').each(function () {
var $field = $(this);
if ($field.attr("id")) {
$field.attr(
"id",
$field.attr("id").replace(/-__prefix__-/, "-" + index + "-")
);
}
if ($field.attr("name")) {
$field.attr(
"name",
$field.attr("name").replace(
/-__prefix__-/, "-" + index + "-"
)
);
}
});
$formset_form.find('label').each(function () {
var $field = $(this);
if ($field.attr("for")) {
$field.attr(
"for",
$field.attr("for").replace(
/-__prefix__-/, "-" + index + "-"
)
);
}
});
$formset_form.find('div').each(function () {
var $field = $(this);
if ($field.attr("id")) {
$field.attr(
"id",
$field.attr("id").replace(
/-__prefix__-/, "-" + index + "-"
)
);
}
});
}
function add_delete_button($formset_form) {
$formset_form.find('input:checkbox[id$=DELETE]')
.each(function () {
var $checkbox = $(this);
var $deleteLink = $(
'<button class="delete btn btn-sm btn-danger mb-3">Remove</button>'
);
$formset_form.append($deleteLink);
$checkbox.closest('.form-group').hide();
});
}
$('.add-inline-form').click(function (e) {
e.preventDefault();
var $formset = $(this).closest('.formset');
var $total_forms = $formset.find('[id$="TOTAL_FORMS"]');
var $new_form = $formset.find('.empty-form').clone(true).attr("id", null);
$new_form.removeClass('empty-form d-none').addClass('formset-form');
set_index_for_fields($new_form, parseInt($total_forms.val(), 10));
$formset.find('.formset-forms').append($new_form);
add_delete_button($new_form);
$total_forms.val(parseInt($total_forms.val(), 10) + 1);
reinit_widgets($new_form);
});
$('.formset-form').each(function () {
$formset_form = $(this);
add_delete_button($formset_form);
reinit_widgets($formset_form);
});
$(document).on('click', '.delete', function (e) {
e.preventDefault();
var $formset = $(this).closest('.formset-form');
var $checkbox = $formset.find('input:checkbox[id$=DELETE]');
$checkbox.attr("checked", "checked");
$formset.hide();
});
});
```
### 實(shí)現(xiàn)原理...
讀者可能通過Django模型管理后臺(tái)已經(jīng)了解到表單集了。表單集在那里用于對(duì)于父模型擁有外鍵的子模型的行內(nèi)機(jī)制。
本節(jié)中,我們使用django-crispy-forms對(duì)idea表單添加了表單集。結(jié)果如下:

可以看出,我們不一定要在表單的結(jié)尾處插入表單集,而是在中間任意有作用之處皆可。本例中,把翻譯放到可翻譯字段之后有實(shí)際意義。
翻譯表單的表單布局像IdeaForm的布局一樣有fieldset,但除此之外,還有識(shí)別每個(gè)模型實(shí)例所需的id及進(jìn)行刪除時(shí)使用的DELETE字段。DELETE字段實(shí)際上是一個(gè)復(fù)選框,在選中時(shí)從數(shù)據(jù)庫(kù)中刪除相應(yīng)內(nèi)容。同時(shí),翻譯的表單helper中有form_tag=False,不生成<form>標(biāo)簽,disable_csrf=True會(huì)不包含CSRF令牌,因?yàn)槲覀円呀?jīng)在其父表單IdeaForm中進(jìn)行過定義。
在表單中,如果請(qǐng)求由POST方法發(fā)送,且表單和表單集有效,那么會(huì)保存表單并創(chuàng)建相應(yīng)的翻譯實(shí)例,不事先進(jìn)行保存。這通過commit=False屬性實(shí)現(xiàn)。我們?yōu)槊總€(gè)翻譯實(shí)例分配idea,然后將翻譯內(nèi)容保存到數(shù)據(jù)庫(kù)中。最后,查看表單集中的表單是有否標(biāo)記為刪除的并從數(shù)據(jù)庫(kù)中進(jìn)行相應(yīng)的刪除。
在translations.html模板中,我們?cè)诒韱渭袖秩久總€(gè)表單,然后添加一個(gè)額外的隱藏空表單,它由JavaScript用來在表單集中生成動(dòng)態(tài)添加的新表單。
表單集中的每個(gè)表單對(duì)每個(gè)字段有前綴。如,表單集的第一個(gè)表單的title字段會(huì)有一個(gè)HTML字段名translations-0-title,同一表單集中的DELETE字段擁有HTML字段名translations-0- DELETE。空表單使用的是__prefix__來代替索引號(hào),如translations-__prefix__-title。這在Django層進(jìn)行的抽象,但在通過JavaScript操作表單集中表單時(shí)需要知曉。
inlines.js JavaScript腳本執(zhí)行了如下操作:
* 對(duì)表單集中的已有表單,它初始化JavaScript驅(qū)動(dòng)的組件(可以使用提示消息、日期或顏色拾取器、地圖等)并創(chuàng)建一個(gè)刪除按鈕,用于代替DELETE復(fù)選框進(jìn)行顯示。
* 在點(diǎn)擊刪除按鈕時(shí),它會(huì)勾選DELETE復(fù)選框并對(duì)用戶隱藏表單。
* 在點(diǎn)擊添加按鈕時(shí),它會(huì)復(fù)制一個(gè)空表單并將__prefix__替換為下一個(gè)可用索引、向列表添加新表單并初始化JavaScript驅(qū)動(dòng)的組件。
### 擴(kuò)展知識(shí)...
JavaScript使用數(shù)組window.WIDGET_INIT_REGISTER,它包含應(yīng)由給定表單初始化組件時(shí)調(diào)用的函數(shù)。要在另一個(gè)JavaScript文件中注冊(cè)新函數(shù)時(shí),可以使用如下代碼:
```
/* site/js/main.js */
function apply_tooltips($formset_form) {
$formset_form.find('[data-toggle="tooltip"]').tooltip();
}
/* register widget initialization for a formset form */
window.WIDGET_INIT_REGISTER = window.WIDGET_INIT_REGISTER || [];
window.WIDGET_INIT_REGISTER.push(apply_tooltips);
```
這會(huì)對(duì)所有包含data-toggle="tooltip"和title屬性的表單應(yīng)用提示消息功能,如下例所示:
```
{% trans "Remove" %}</button>
```
### 相關(guān)內(nèi)容
* *通過django-crispy-forms創(chuàng)建表單布局*一節(jié)
* [**第4章 模板和JavaScript**](
https://alanhou.org/django3-templates-javascript/)中的*編排base.html模板*一節(jié)
本文首發(fā)地址: [Alan Hou 的人個(gè)博客](
https://alanhou.org/forms-views/)