Django 3网页开发指南第4版 第3章 表单和视图PART 1

本文完整目录请见 Django 3网页开发指南 - 第4版

本章包含如下内容:

  • 使用CRUDL函数创建应用
  • 保存模型实例的作者
  • 上传图片
  • 通过自定义模型创建表单布局
  • 通过django-crispy-forms创建表单布局
  • 处理formsets
  • 过滤对象列表
  • 管理分页列表
  • 编写基于类的视图
  • 添加Open Graph和Twitter Card数据
  • 添加schema.org用词
  • 生成PDF文档
  • 通过Haystack和Whoosh实现多语言搜索
  • 通过Elasticsearch DSL实现多语言搜索

引言

在模型中定义了数据库结构时,视图提供了要向用户显示内容或让用户输入新数据及更新数据的端点(endpoint)。本章中, 我们集中学习管理表单的视图、列表视图及向HTML生成替代输出的视图。这最简化的示例中,URL规则及模板创建就交给读者了。

技术要求

要使用本章中的代码,同时,读者需要最新的稳定版Python、MySQL或PostgreSQL数据库以及虚拟环境中创建的Django项目中。部分小节要求有特定的Python依赖。此外,要生成PDF文件,需要有cairo、pango、gdk-pixbuf及libffi库。搜索需要用到Elasticsearch服务端。更多详情在相应的小节中会进行讨论。

本章中的大部分模板会使用Bootstrap 4 CSS框架来保持美观度。

本章中的代码请见GitHub仓库的Chapter03目录。

使用CRUDL函数创建应用

在计算机科学领域,CRUDL是Create(创建/增), Read(读取/查), Update(更新/改), Delete(删除/删)和List(列举)函数的缩写。很多具有交互功能的Django项目要求我们实现所有这些函数来对网站进行数据的管理。本小节中,我们学习如何通过这些基本函数来创建URL和视图。

准备工作

我们来创建一个名为ideas的应用并将添加到设置文件的INSTALLED_APPS中。在该应用中创建如下包含带有翻译文件的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并使用了一个模型翻译表。阅读使用模型mixin操作模型翻译数据表小节了解更多内容。我们将在本章的剩余小节中使用ideas应用及这些模型。

此外,创建一个同级categories应用并包含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

如何实现...

Django中的CRUDL功能由表单、视图和URL规则所组成。下面进行创建:

  1. 在ideas应用中新增forms.py文件,添加用于对Idea模型实例进行新增和修改的模型表单:
# myprojects/apps/ideas/forms.py
    from django import forms 
    from .models import Idea

    class IdeaForm(forms.ModelForm): 
      class Meta:
        model = Idea 
        fields = "__all__"
  1. 在ideas应用中添加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)
  1. 在ideas应用创建urls.py文件并添加URL规则:
# 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("/", IdeaDetail.as_view(), name="idea_detail"), 
      path("/change/", add_or_change_idea, name="change_idea"),
      path("/delete/", delete_idea, name="delete_idea"),
    ]
  1. 现在我们把这些URL规则插入到项目的URL配置中。我们还会包含Django社区的auth应用中的账户URL规则,这样@login_required装饰器就可以正常运行了:
# 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)
  1. 现在可以创建如下模板了:
    • 包含登录表单的registration/login.html
    • 包含对ideas进行列举的ideas/idea_list.html
    • 有关单个idea详情的ideas/idea_detail.html
    • 带有添加或修改 idea 表单的ideas/idea_form.html
    • 包含确认删除 idea 空表单的ideas/idea_deleting_confirmation.html

在模板中,可以通过如下命名空间和path名称来调用ideas应用的URL:

{% load i18n %}
{% trans "Change this idea" %}
{% trans "Add idea" %}

ℹ️如果碰到问题或是希望节省时间,可以查看本书代码文件中的相应模板,地址为https://github.com/PacktPublishing/Django-3-Web-Development-Cookbook-Fourth-Edition/tree/master/ch03/myproject_virtualenv/src/django-myproject/myproject/templates/ideas

实现原理...

本例中,我们使用UUID字段作为Idea模型的主键。借助这一ID,每个idea的唯一URL都是无法靠猜来知道的。另一种方式是对URL使用slug字段,但这时要确保会生成slug且在整个网站中是唯一的。

出于安全考虑不推荐对URL使用默认的递增ID:那样用户就能够推算出数据库中有多少条记录并可以尝试访问他们可能没有权限访问的前一条或后一条记录。

在我们示例中,我们使用通用的视图类来列出并读取idea以及视图函数来增、改、删这些idea。在数据库中更改记录的视图通过@login_required装饰器要求为认证用户。使用视图类或针对所有的CRUDL函数的视图函数都没有问题。

在成功新增或修改idea之后,这些用户会被重定向到详情视图。在删除idea之后,用户会被重定向到列表视图。

扩展知识...

此外可以使用Django消息框架来在成功添加、修改或删除时在页面顶部显示成功消息。

可以在官方文档中阅读相关内容。

相关内容

  • 第2章 模型和数据库结构使用模型mixin一节
  • 第2章 模型和数据库结构操作模型翻译数据表一节
  • 保存模型实例的作者一节
  • 第4章 模板和JavaScript安排base.html模板一节

保存模型实例的作者

Django的每个视图的第一个参数是HttpRequest对象,按照惯例名称为request。它包含有关由浏览器或其它客户端发请求的元数据,包含当前语言码、用户数据、cookie和session数据。默认,视图使用表单来接受GET或POST数据、文件、初始数据及其它参数;但是它们不是默认就可以访问HttpRequest对象。在某些情况下,额外将HttpRequest传递给表单会很用,尤其是在想要根据其它请求数据或在表单中处理当前用户或IP的保存时过滤掉表单字段的选项时。

在本节中,我们将学习表单的示例,其中可以添加或修改idea,并将当前用户保存为作者。

准备工作

我们将在前一小节示例的基础上进行演示。

如何实现...

要完成本小节,执行如下两步:

  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
  1. 修改视图来添加或修改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)
   

实现原理...

我们来看下这个表单。首先,我们从表单中排除了author字段,因为希望使用程序来进行处理。我们重写了init()方法来接收HttpRequest作为第一个参数并在表单中进行存储。模型表单的save()方法处理模型的存储。commit参数告诉模型表单立即存储实例或者是创建并调用实例,但暂不保存。在本例中,我们获取了实例但不进行保存,然后通过当前用户为author赋值。最后在commit为True时我们保存了该实例。我们会动态调用表单所添加的save_m2m() 方法来保存多对多关联,如categories。

在视图中,我们只对表单传递request变量作为每一个参数。

相关内容

  • 使用CRUDL函数创建应用一节
  • 上传图片一节

上传图片

在本节中,我们将了解处理图片上传的最简单方式。我们会对Idea模型添加一个picture字段,并为不同用途创建不同大小版本的图片。

准备工作

对于具有版本的图片,我们需要用到Pillow和django-imagekit库。下面通过pip来在虚拟环境中进行安装(并在requirements/_base.txt中进行添加):

(env)$ pip install Pillow
(env)$ pip install django-imagekit==4.0.2

然后在设置的INSTALLED_APPS添加imagekit。

如何实现...

执行如下步骤完成本小节中的开发:

  1. 修改Idea模型,添加一个picture字段以及各图片版本规格:
# 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)
  1. 和前面小节中一样,在forms.py中为Idea模型创建模型表单IdeaForm。
  2. 在添加或修改idea的视图中,确保在表单中在request.POST之后post请求还提交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)
  1. 在模板中,记得为multipart/form- data设置编码类型,如下:
{% csrf_token %} {{ form.as_p }}

ℹ️如果像通过django-crispy-forms创建表单布局一节中所描述那样使用django-crispy-form,则会在表单中自动添加enctype属性。

实现原理...

Django模型表单是动态地由模型进行创建的。它们提供来自模型的指定字段,因此无需在表单中手动重新定义这些字段。在前例中,我们为Idea模型创建了一个模型表单。在保存表单时,表单会知道在数据库中如何保存每个字段、如何上传文件并在media目录中进行保存。

本例中的upload_to() 函数用于将图片保存到指定目录并重新定义其名称,这样不会与其它模型实例中的文件名相冲突。每个文件保存的路径类似ideas/2020/01/0422c6fe- b725-4576-8703-e2a9d9270986.jpg,其中包含上传的年、月以及Idea实例的主键。

一些文件系统(如FAT32和NTFS)对每个目录中的文件存在上限;因此按照上传日期、字母或其它条件分割到不同目录是一种好实践。

我们使用django-imagekit中的ImageSpecField来创建3种图片规格:

  • picture_social用于社交分享
  • picture_large用于详情视图
  • picture_thumbnail用于列表视图

图片规格在数据库中不进行关联,而只是在CACHE/images/ideas/2020/01/0422c6fe-b725-4576-8703- e2a9d9270986/这样的文件路径中按默认文件存储进行保存。

在模板中,可以按如下使用原始或指定图片版本:



在Idea模型定义最后,我们重写了delete()方法来在删除Idea实例本身之前从磁盘上删除各版本图片及图片本身。

相关内容

  • 通过django-crispy-forms创建表单布局一节
  • 第4章 模板和JavaScript中的编排base.html模板一节
  • 第4章 模板和JavaScript中的使用响应式图片一节

通过自定义模型创建表单布局

在Django的早前版本中,所有表单的渲染都独立放在Python代码中处理,但从Django 1.11开始,就引入了基于模板的表单组件渲染。在本小节中,我们将学习如何对表单组件使用自定义模板。我们会使用Django后台表单来讲解自定义组件模板如何提升字段的易用性。

准备工作

我们先创建Idea模型的默认后台管理并添加翻译:

# 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"]}),
  ]

此时如访问ideas的后台表单,界面类似下面这样:

如何实现...

学习本小节,需执行如下步骤:

  1. 通过将django.forms添加到INSTALLED_APPS中、在模板配置中将APP_DIRS标记设置为True并使用TemplatesSetting表单渲染器来确保模板系统能够发现自定义模板:
# 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"
  1. 编辑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"]}),
     ]
   
  1. 最后,为picture字段创建一个模板:
{# core/widgets/image.html #}
   {% load i18n %}

   
{% if widget.is_initial %} {% if not widget.required %}
{{ widget.clear_checkbox_label }}: {% endif %}
{{ widget.input_text }}: {% endif %}
{% trans "Available formats are JPG, GIF, and PNG." %} {% trans "Minimal size is 800 x 800 px." %}

实现原理...

此时再访问ideas的后台界面,效果如下:

这里有两处变动:

  • 分类选项此时使用带有多个复选框的组件
  • 图片字段通过指定的模板进行了渲染,使用所指定文件类型和尺寸显示着图片预览和帮助文件。

我们在这里所做的是,重写idea模型表单、修改分类组件及图片字段的模板。

Django中默认的表单渲染器是django.forms.renderers.DjangoTemplates,它仅在应用目录中搜索模板。我们将其修改为django.forms.renderers.TemplatesSetting让它同时还在DIRS路径中进行查找。

相关内容

  • 第2章 模型和数据库结构操作模型翻译数据表一节
  • 上传图片一节
  • 通过django-crispy-forms创建表单布局一节

通过django-crispy-forms创建表单布局

Django应用django-crispy-forms让我们可以使用如下一种CSS框架构建、自定义及复用表单:Uni-Form、Bootstrap 3、Bootstrap 4或Foundation。django-crispy-forms的使用与Django自带的fieldsets有些类似;但它更为高级、定制化更强。可以在Python代码中定义表单布局而无需担心每个字段在HTML中如何展示。此外,如果需要添加指定的HTML属性或标签,也可以轻松实现。django-crispy-forms所使用的所有标记位于templates内,可在需要时进行重写。

本小节中,我们将使用用于开发响应式、mobile-first网页项目的流行前台框架Bootstrap 4,来为前台表单创建漂亮的布局,用于添加或编辑ideas。

准备工作

首先我们使用在本章中创建的ideas应用。接着逐一执行如下步骤:

  1. 记得为网站创建一个base.html 模板。更多详情参见第4章 模板和JavaScript中安排base.html模板一节。
  2. 根据https://getbootstrap.com/docs/4.3/getting-started/introduction/将Bootstrap 4前端框架中的CSS和JS文件集成到base.html模板中。
  3. 通过pip在虚拟环境中安装django-crispy-forms(并将其添加到requirements/_base.txt中):
(env)$ pip install django-crispy-forms
  1. 在设置的INSTALLED_APPS中添加crispy_forms,然后设置bootstrap4为项目中所使用的模板包:
# myproject/settings/_base.py
   INSTALLED_APPS = ( 
     # ...
     "crispy_forms",
     "ideas", 
   )
   # ...
   CRISPY_TEMPLATE_PACK = "bootstrap4"
   

如何实现...

按照如下步骤操作:

  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
  1. 然后通过如下内容创建picture_guidelines.html模板:
{# ideas/includes/picture_guidelines.html #}
   {% load i18n %}
   

{% trans "Available formats are JPG, GIF, and PNG." %} {% trans "Minimal size is 800 × 800 px." %}

  1. 最后更新ideas表单的模板:
{# ideas/idea_form.html #}
   {% extends "base.html" %}
   {% load i18n crispy_forms_tags static %}

   {% block content %}
     {% trans "List of ideas" %}
     

{% if idea %} {% blocktrans trimmed with title=idea.translated_title %} Change Idea "{{ title }} {% endblocktrans %} {% else %} {% trans "Add Idea" %} {% endif %}

{% crispy form %} {% endblock %}

实现原理...

在ideas的模型表单中,我们创建了一个表单帮助类,布局由主字段集、图片字段集、分类字段集和提交按钮所组成。每个字段集由不同字段组成。每个字段集、字段或按钮可以带有其它参数,成为字段的属性,如rows="3"或placeholder=_("Please enter a title")。对于HTML类和id属性,有特定的参数css_class和css_id。

idea表单的页面类似下面这样:

和前面小节类似,我们修改了目录字段的组件并为图片字段添加了额外的帮助文本。

扩展知识...

前例对于基础使用已经足够了。但如果需要在表单中设置指定标记,则仍需重写并修改django-crispy-forms应用的模板,因为在Python文件中没有硬编码的标记,而是所有生成的标记均通过模板进行渲染。只需将django-crispy-forms中的模板拷贝到项目模板目录中并按需修改。

相关内容

  • 使用CRUDL函数创建应用一节
  • 通过自定义模型创建表单布局一节
  • 通过django-crispy-forms创建表单布局
  • 过滤对象列表一节
  • 管理分页列表一节
  • 编写基于类的视图一节
  • 第4章 模板和JavaScript安排base.html模板一节

处理formsets

除了常规表单或模型表单外,Django还有一个表单集的概念。这些是允许一次性创建或修改多个实例的同一类型表单的集合。Django表单集可通过JavaScript丰富功能,它让我们可以对页面动态添加表单集。这也本小节要讨论的。我们会扩展ideas表单来允许对同一页面添加不同语言的翻译。

准备工作

我们将继续使用前一小节通过django-crispy-forms创建表单布局中的IdeaForm。

如何实现...

按照如下步骤:

  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
  1. 然后,同一文件的最后添加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)
   
  1. 修改视图来添加或修改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)
  1. 然后,编辑idea_form.html模板并在最后添加对inlines.js脚本的引用:
{# ideas/idea_form.html #}
   {% extends "base.html" %}
   {% load i18n crispy_forms_tags static %}

   {% block content %}
       {% trans "List of ideas" %}
       

{% if idea %} {% blocktrans trimmed with title=idea.translated_title %} Change Idea "{{ title }}" {% endblocktrans %} {% else %} {% trans "Add Idea" %} {% endif %}

{% crispy form %} {% endblock %} {% block js %} {% endblock %}
  1. 为翻译表单集创建模板:
{# ideas/forms/translations.html #}
   {% load i18n crispy_forms_tags %}
   
{{ translations_formset.management_form }}

{% trans "Translations" %}

{% for formset_form in translations_formset %}
{% crispy formset_form %}
{% endfor %}
{% crispy translations_formset.empty_form %}
  1. 最后,添加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 = $(
                    ''
                );
                $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();
        }); 
    });
    

实现原理...

读者可能通过Django模型管理后台已经了解到表单集了。表单集在那里用于对于父模型拥有外键的子模型的行内机制。

本节中,我们使用django-crispy-forms对idea表单添加了表单集。结果如下:

可以看出,我们不一定要在表单的结尾处插入表单集,而是在中间任意有作用之处皆可。本例中,把翻译放到可翻译字段之后有实际意义。

翻译表单的表单布局像IdeaForm的布局一样有fieldset,但除此之外,还有识别每个模型实例所需的id及进行删除时使用的DELETE字段。DELETE字段实际上是一个复选框,在选中时从数据库中删除相应内容。同时,翻译的表单helper中有form_tag=False,不生成

标签,disable_csrf=True会不包含CSRF令牌,因为我们已经在其父表单IdeaForm中进行过定义。

在表单中,如果请求由POST方法发送,且表单和表单集有效,那么会保存表单并创建相应的翻译实例,不事先进行保存。这通过commit=False属性实现。我们为每个翻译实例分配idea,然后将翻译内容保存到数据库中。最后,查看表单集中的表单是有否标记为删除的并从数据库中进行相应的删除。

在translations.html模板中,我们在表单集中渲染每个表单,然后添加一个额外的隐藏空表单,它由JavaScript用来在表单集中生成动态添加的新表单。

表单集中的每个表单对每个字段有前缀。如,表单集的第一个表单的title字段会有一个HTML字段名translations-0-title,同一表单集中的DELETE字段拥有HTML字段名translations-0- DELETE。空表单使用的是prefix来代替索引号,如translations-prefix-title。这在Django层进行的抽象,但在通过JavaScript操作表单集中表单时需要知晓。

inlines.js JavaScript脚本执行了如下操作:

  • 对表单集中的已有表单,它初始化JavaScript驱动的组件(可以使用提示消息、日期或颜色拾取器、地图等)并创建一个删除按钮,用于代替DELETE复选框进行显示。
  • 在点击删除按钮时,它会勾选DELETE复选框并对用户隐藏表单。
  • 在点击添加按钮时,它会复制一个空表单并将prefix替换为下一个可用索引、向列表添加新表单并初始化JavaScript驱动的组件。

扩展知识...

JavaScript使用数组window.WIDGET_INIT_REGISTER,它包含应由给定表单初始化组件时调用的函数。要在另一个JavaScript文件中注册新函数时,可以使用如下代码:

/* 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);

这会对所有包含data-toggle="tooltip"和title属性的表单应用提示消息功能,如下例所示:

{% trans "Remove" %}

相关内容

  • 通过django-crispy-forms创建表单布局一节
  • 第4章 模板和JavaScript中的编排base.html模板一节

本文首发地址: Alan Hou 的人个博客

你可能感兴趣的:(Django 3网页开发指南第4版 第3章 表单和视图PART 1)