Skip to content

Latest commit

 

History

History
3022 lines (2334 loc) · 109 KB

File metadata and controls

3022 lines (2334 loc) · 109 KB

第3章 表单和视图

本章包含如下内容:

  • 使用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__"
    
  2. 在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)
    
  3. 在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("<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. 现在我们把这些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)
    
  5. 现在可以创建如下模板了:

    • 包含登录表单的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 %}
<a href="{% url 'ideas:change_idea' pk=idea.pk %}">{% trans "Change this idea" %}</a>
<a href="{% url 'ideas:add_idea' %}">{% trans "Add idea" %}</a>

ℹ️如果碰到问题或是希望节省时间,可以查看本书代码文件中的相应模板,地址为https://github.com/alanhou/django3-cookbook/tree/master/Chapter03/django-myproject/myproject/templates/ideas

实现原理...

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

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

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

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

扩展知识...

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

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

相关内容

保存模型实例的作者

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

实现原理...

我们来看下这个表单。首先,我们从表单中排除了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)
    
  2. 和前面小节中一样,在forms.py中为Idea模型创建模型表单IdeaForm。

  3. 在添加或修改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)
    
  4. 在模板中,记得为multipart/form- data设置编码类型,如下:

    <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创建表单布局一节中所描述那样使用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/这样的文件路径中按默认文件存储进行保存。

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

<img src="{{ idea.picture.url }}" alt="" />
<img src="{{ idea.picture_large.url }}" alt="" />

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

相关内容

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

在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的后台表单,界面类似下面这样:

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"
    
  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字段创建一个模板:

    {# 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>
    

实现原理...

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

Django后台 Idea修改表单

这里有两处变动:

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

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

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

相关内容

通过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
    
  4. 在设置的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
    
  2. 然后通过如下内容创建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 %}
    

实现原理...

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

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

Django 3 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
    
  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模板并在最后添加对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. 为翻译表单集创建模板:

    {# 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();
        }); 
    });
    

实现原理...

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

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

Django 3添加翻译表单

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

翻译表单的表单布局像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属性的表单应用提示消息功能,如下例所示:

<button data-toggle="tooltip" title="{% trans 'Remove this translation' %}">{% trans "Remove" %}</button>

相关内容

过滤对象列表

在网页开发中,除带表单的视图外,对象列表视图和详情视图也很常见。列表视图可以简单列举已排序的对象,如,按字母排序或创建日期排序;但庞大的数据量对用户不友好。为实现最好的可用性和便捷性,应当能够通过所有可用分类对内容进行过滤。本节中,我们将学习用于对过分类号过滤列表视图的模式。

我们会创建一个ideas的列表视图,可通过作者、分类或评分进行过滤。应用了Bootstrap 4之后效果类似下面这样:

Django 3�过滤列表

准备工作

这个过滤示例,我们将使用Idea模型关联作者和分类进行过滤。也可以通过评分进行过滤,即带选项的PositiveIntegerField。我们使用的是前面小节中所创建的ideas应用。

如何实现...

按照如下步骤完成本小节的学习:

  1. 创建IdeaFilterForm进行可用分类的过滤:

    # myproject/apps/ideas/forms.py
    from django import forms
    from django.utils.translation import ugettext_lazy as _ 
    from django.db import models
    from django.contrib.auth import get_user_model
    
    from myproject.apps.categories.models import Category 
    
    from .models import RATING_CHOICES
    
    User = get_user_model()
    
    
    class IdeaFilterForm(forms.Form): 
        author = forms.ModelChoiceField(
            label=_("Author"), 
            required=False, 
            queryset=User.objects.annotate(
                idea_count=models.Count("authored_ideas") 
            ).filter(idea_count__gt=0),
        )
        category = forms.ModelChoiceField(
            label=_("Category"), 
            required=False, 
            queryset=Category.objects.annotate(
                idea_count=models.Count("category_ideas") 
            ).filter(idea_count__gt=0),
        )
        rating = forms.ChoiceField(
            label=_("Rating"), required=False, choices=RATING_CHOICES 
        )
    
  2. 创建idea_list视图来列举过滤后的ideas:

    # myproject/apps/ideas/views.py
    from django.shortcuts import render, redirect, get_object_or_404 
    from django.conf import settings
    
    from .forms import IdeaFilterForm
    from .models import Idea, RATING_CHOICES
    
    PAGE_SIZE = getattr(settings, "PAGE_SIZE", 24)
    
    def idea_list(request):
        qs = Idea.objects.order_by("title") 
        form = IdeaFilterForm(data=request.GET)
    
        facets = { 
            "selected": {},
            "categories": {
                "authors": form.fields["author"].queryset, 
                "categories": form.fields["category"].queryset, 
                "ratings": RATING_CHOICES,
            }, 
        }
    
        if form.is_valid(): 
            filters = (
                # query parameter, filter parameter
                ("author", "author"),
                ("category", "categories"),
                ("rating", "rating"),
            )
            qs = filter_facets(facets, qs, form, filters)
    
        context = {"form": form, "facets": facets, "object_list": qs} 
        return render(request, "ideas/idea_list.html", context)
    
  3. 在同一个文件中添加帮助函数filter_facets():

    def filter_facets(facets, qs, form, filters):
        for query_param, filter_param in filters:
            value = form.cleaned_data[query_param] 
            if value:
                selected_value = value
                if query_param == "rating":
                    rating = int(value) 
                    selected_value = (rating, dict(RATING_CHOICES)[rating]) 
                facets["selected"][query_param] = selected_value 
                filter_args = {filter_param: value}
                qs = qs.filter(**filter_args).distinct()
        return qs
    
  4. 如未创建,请创建base.html模板。可以参照第4章 模板和JavaScript中的编排base.html模板一节的示例。

  5. 创建idea_list.html 模型并添加如下内容:

    {# ideas/idea_list.html #}
    {% extends "base.html" %} 
    {% load i18n utility_tags %}
    
    {% block sidebar %}
      {% include "ideas/includes/filters.html" %}
    {% endblock %}
    
    {% block main %}
      <h1>{% trans "Ideas" %}</h1>
      {% if object_list %}
        {% for idea in object_list %}
          <a href="{{ idea.get_url_path }}" class="d-block my-3">
            <div class="card">
              <img src="{{ idea.picture_thumbnail.url }}" alt="" />
              <div class="card-body">
                <p class="card-text">{{ idea.translated_title }}</p>
              </div>
            </div>
          </a>
        {% endfor %}
      {% else %}
        <p>{% trans "There are no ideas yet." %}</p>
      {% endif %}
      <a href="{% url 'ideas:add_idea' %}" class="btn btn-primary">{% trans "Add idea" %}</a>
    {% endblock %}
    
  6. 然后对过滤器创建模板。这个模板使用 {% modify_query %}模板标签来生成过滤器的URL,参见第5章 自定义模板过滤器和标签中的创建模板标签来修改请求查询参数一节:

    {# ideas/includes/filters.html #}
    {% load i18n utility_tags %}
    <div class="filters panel-group" id="accordion">
      {% with title=_('Author') selected=facets.selected.author %} 
        <div class="panel panel-default my-3">
          {% include "misc/includes/filter_heading.html" with title=title %}
          <div id="collapse-{{ title|slugify }}" 
            class="panel-collapse{% if not selected %}
            collapse{% endif %}">
            <div class="panel-body"><div class="list-group">
              {% include "misc/includes/filter_all.html" with param="author" %}
              {% for cat in facets.categories.authors %} 
                <a class="list-group-item
                  {% if selected == cat %} 
                  active{% endif %}"
                  href="{% modify_query "page" 
                  author=cat.pk %}">
                  {{ cat }}</a>
              {% endfor %}
            </div></div>
          </div>
        </div>
    {% endwith %}
    {% with title=_('Category') selected=facets.selected.category %}
      <div class="panel panel-default my-3">
        {% include "misc/includes/filter_heading.html" with title=title %}
        <div id="collapse-{{ title|slugify }}" 
          class="panel-collapse{% if not selected %}
          collapse{% endif %}">
          <div class="panel-body"><div class="list-group">
            {% include "misc/includes/filter_all.html" with param="category" %}
            {% for cat in facets.categories.categories %} 
              <a class="list-group-item
                {% if selected == cat %} active{% endif %}"
                href="{% modify_query "page" category=cat.pk %}">
                {{ cat }}</a>
            {% endfor %}
          </div></div>
        </div>
      </div>
    {% endwith %}
    {% with title=_('Rating') selected=facets.selected.rating %}
      <div class="panel panel-default my-3">
        {% include "misc/includes/filter_heading.html" with title=title %}
        <div id="collapse-{{ title|slugify }}" 
          class="panel-collapse{% if not selected %}
          collapse{% endif %}">
          <div class="panel-body"><div class="list-group">
            {% include "misc/includes/filter_all.html" with param="rating" %}
            {% for r_val, r_display in facets.categories.ratings %}
              <a class="list-group-item
              {% if selected.0 == r_val %} active{% endif %}"
                href="{% modify_query "page" rating=r_val %}">
                {{ r_display }}</a>
            {% endfor %}
            </div></div>
          </div>
        </div>
      {% endwith %}
    </div>
    
  7. 每个分类在边栏中遵循通用的模式,因此我们可以创建并包含带有通用部分的模板。首先有过滤头部,对应文件为 misc/includes/filter_heading.html,如下:

    {# misc/includes/filter_heading.html #}
    {% load i18n %}
    <div class="panel-heading">
      <h6 class="panel-title">
        <a data-toggle="collapse" data-parent="#accordion"
          href="#collapse-{{ title|slugify }}"> 
          {% blocktrans trimmed %}
            Filter by {{ title }}
          {% endblocktrans %}
        </a> 
      </h6>
    </div>
    
  8. 然后每个过滤器会包含重置该分类过滤器的链接,这里位于misc/includes/filter_all.html。这个模板还使用{% modify_query %}模板标签,参见第5章 自定义模板过滤器和标签中的创建模板标签来修改请求查询参数一节:

    {# misc/includes/filter_all.html #}
    {% load i18n utility_tags %}
    <a class="list-group-item {% if not selected %}active{% endif %}"
      href="{% modify_query "page" param %}"> 
      {% trans "All" %}
    </a>
    
  9. 这个idea列表需要添加到ideas应用的URL中:

    # myproject/apps/ideas/urls.py
    from django.urls import path
    
    from .views import idea_list
    
    urlpatterns = [
      path("", idea_list, name="idea_list"), 
      # other paths...
    ]
    

实现原理...

我们使用facets字典,传递给模板上文来知道有哪些过滤器以及选中了哪些过滤器。再深入一点,facets字典包含两块:categories字典和selected字典。categories字典包含查询集或所有分类的选项。selected字典包含当前每个分类的选中值。在IdeaFilterForm中,我们确保只有至少包含一个idea的分类和作者被列举出来。

在视图中,我们检查表单中的查询参数是否有效,然后根据所选中分类过滤对象的查询集。此外,我们为facets字典设置所选中的值,这个字典会传递给模板。

在模板中,对facets字典中的每个分类,我们列举出所有分类并当前选中的分类为活跃状态。如果未选中所给定分类,我们默认标记All为活跃值。

相关内容

管理分页列表

如果动态地修改了对象列表或其总数超过24等数,很有可能会需要通过分页来提供更好的用户体验。分页不是查询全部集合,而是在数据集中取出对应一页数量的指定个数据项。我们还提供链接来让用户访问组成完整数据的其它页面。Django有一个管理分页数据的类,本节中我们就来学习如何使用。

准备工作

我们使用过滤对象列表一节中ideas应用的模型、表单和视图。

如何实现...

按照如下步骤来向ideas的列表视图添加分页功能:

  1. 在views.py文件中导入Django分页类。我们将在过滤代码后对idea_list视图添加分页管理。同时,还会通过向object_list键分配page来微调上下文字典:

    # myproject/apps/ideas/views.py
    from django.shortcuts import render, redirect, get_object_or_404 
    from django.conf import settings
    from django.core.paginator import (EmptyPage, PageNotAnInteger, Paginator)
    
    from .forms import IdeaFilterForm
    
    from .models import Idea, RATING_CHOICES
    
    PAGE_SIZE = getattr(settings, "PAGE_SIZE", 24)
    
    def idea_list(request):
    
      qs = Idea.objects.order_by("title") 
      form = IdeaFilterForm(data=request.GET)
      facets = { 
        "selected": {},
        "categories": {
          "authors": form.fields["author"].queryset, 
          "categories": form.fields["category"].queryset, 
          "ratings": RATING_CHOICES,
        }, 
      }
    
      if form.is_valid(): 
        filters = (
          # query parameter, filter parameter
          ("author", "author"),
          ("category", "categories"),
          ("rating", "rating"),
        )
        qs = filter_facets(facets, qs, form, filters)
    
      paginator = Paginator(qs, PAGE_SIZE) 
      page_number = request.GET.get("page") 
      try:
        page = paginator.page(page_number) 
      except PageNotAnInteger:
        # If page is not an integer, show first page.
        page = paginator.page(1) 
      except EmptyPage:
        # If page is out of range, show last existing page. 
        page = paginator.page(paginator.num_pages)
    
      context = { 
        "form": form,
        "facets": facets,
        "object_list": page, 
      }
      return render(request, "ideas/idea_list.html", context)
    
  2. 修改idea_list.html模板如下:

    {# ideas/idea_list.html #}
    {% extends "base.html" %} 
    {% load i18n utility_tags %}
    
    {% block sidebar %}
      {% include "ideas/includes/filters.html" %}
    {% endblock %}
    
    {% block main %}
      <h1>{% trans "Ideas" %}</h1>
      {% if object_list %}
        {% for idea in object_list %}
          <a href="{{ idea.get_url_path }}" class="d-block my-3"> 
            <div class="card">
              <img src="{{ idea.picture_thumbnail.url }}" alt="" />
              <div class="card-body">
                <p class="card-text">{{ idea.translated_title }}</p>
              </div>
            </div> 
          </a>
        {% endfor %}
        {% include "misc/includes/pagination.html" %}
      {% else %}
        <p>{% trans "There are no ideas yet." %}</p>
      {% endif %}
      <a href="{% url 'ideas:add_idea' %}" class="btn btn-primary">{% trans "Add idea" %}</a>
    {% endblock %}
    
  3. 创建分类组件模板:

    {# misc/includes/pagination.html #}
    {% load i18n utility_tags %}
    {% if object_list.has_other_pages %}
      <nav aria-label="{% trans 'Page navigation' %}">
    
        <ul class="pagination">
          {% if object_list.has_previous %}
            <li class="page-item"><a class="page-link" href="{% modify_query page=object_list.previous_page_number %}">{% trans "Previous" %}</a></li>
          {% else %}
            <li class="page-item disabled"><span class="page- link">{% trans "Previous" %}</span></li>
          {% endif %}
    
          {% for page_number in object_list.paginator .page_range %}
            {% if page_number == object_list.number %} 
              <li class="page-item active">
                <span class="page-link">{{ page_number }} 
                  <span class="sr-only">{% trans "(current)" %}</span>
                </span>
              </li>
            {% else %}
              <li class="page-item">
                <a class="page-link" href="{% modify_query page=page_number %}">{{ page_number }}</a>
              </li>
            {% endif %}
          {% endfor %}
    
          {% if object_list.has_next %}
            <li class="page-item"><a class="page-link" href="{% modify_query page=object_list.next_page_number %}"> {% trans "Next" %}</a></li>
          {% else %}
            <li class="page-item disabled"><span class="page-link">{% trans "Next" %}</span></li>
          {% endif %}
        </ul> 
      </nav>
    {% endif %}
    

实现原理...

在浏览器中查看结果时,会看到类似下面这样的分页控制器:

Django 3 分页控制器

这是如何实现的呢?在过滤好查询集合后,我们会创建一个paginator对象,传递查询集合及每页希望显示的最大条数,本例中为24。然后,我们会通过查询参数page读取当前页数。下一步是通过paginator获取当前页对象。如果页数不是整型,则获取第一页。如果页数超出可显示页面数,获取最后一页。page对象中有上图所示分页组件所需的方法和属性。同时,page类似查询集合,可以进行遍历并取出页面中的部分项。

模板中所示的代码片断用Bootstrap 4前端框架创建一个分页组件。仅在页面多于当前页时才会显示分页控件。里面有上一页、下一页,以及组件中所有页码的列表。当前页码进行了高亮显示。我们使用{% modify_query %}来将链接生成URL,这部分在稍后的第5章 自定义模板过滤器和标签中的创建模板标签来修改请求查询参数一节中会进行详细讲解。

相关内容

编写基于类的视图

Django视图是可接收请求并返回响应的可调用函数。除了函数形式的视图外,Django还提供了一种通过类定义视图的方式。这种方式在想要创建可复用的模块化视图或合并通用mixin的视图时非常有用。本小节中,我们会将前面的函数视图idea_list化为类视图IdeaListView。

准备工作

创建类似前面过滤对象列表管理分页列表小节中的模型、表单和模板。

如何实现...

按照如下步骤来完成本小节的内容:

  1. 我们的类视图IdeaListView继承Django的View类并重载get()方法:

    # myproject/apps/ideas/views.py
    from django.shortcuts import render, redirect, get_object_or_404 
    from django.conf import settings
    from django.core.paginator import (EmptyPage, PageNotAnInteger, Paginator)
    from django.views.generic import View
    
    from .forms import IdeaFilterForm
    from .models import Idea, RATING_CHOICES
    
    PAGE_SIZE = getattr(settings, "PAGE_SIZE", 24)
    
    
    class IdeaListView(View):
      form_class = IdeaFilterForm 
      template_name = "ideas/idea_list.html"
      
      def get(self, request, *args, **kwargs):
        form = self.form_class(data=request.GET)
        qs, facets = self.get_queryset_and_facets(form) 
        page = self.get_page(request, qs)
        context = {"form": form, "facets": facets, "object_list": page}
        return render(request, self.template_name, context)
      
      def get_queryset_and_facets(self, form): 
        qs = Idea.objects.order_by("title") 
        facets = {
          "selected": {},
          "categories": {
            "authors": form.fields["author"].queryset, 
            "categories": form.fields["category"].queryset, 
            "ratings": RATING_CHOICES,
          }, 
        }
      
        if form.is_valid(): 
          filters = (
            # query parameter, filter parameter
            ("author", "author"),
            ("category", "categories"),
            ("rating", "rating"),
          )
          qs = self.filter_facets(facets, qs, form, filters) 
        return qs, facets
      
      @staticmethod
      def filter_facets(facets, qs, form, filters):
        for query_param, filter_param in filters: 
          value = form.cleaned_data[query_param] 
          if value:
            selected_value = value
            if query_param == "rating":
              rating = int(value) 
              selected_value = (rating, dict(RATING_CHOICES)[rating]) 
              facets["selected"][query_param] = selected_value 
              filter_args = {filter_param: value}
              qs = qs.filter(**filter_args).distinct()
          return qs
    
      def get_page(self, request, qs):
        paginator = Paginator(qs, PAGE_SIZE) 
        page_number = request.GET.get("page") 
        try:
          page = paginator.page(page_number) 
        except PageNotAnInteger:
          page = paginator.page(1) 
        except EmptyPage:
          page = paginator.page(paginator.num_pages) 
        return page
    
  2. 我们需要在URL配置中使用视图类创建URL规则。你可能已经使用视图函数idea_list添加过规则,方法相似。在URL规则中包含视图类,需要使用as_view()方法如下:

    # myproject/apps/ideas/urls.py
    from django.urls import path
    
    from .views import IdeaListView
    
    urlpatterns = [
      path("", IdeaListView.as_view(), name="idea_list"),
      # other paths... 
    ]
    

实现原理...

由HTTP GET请求所调用的get()方法中所执行的操作如下:

  • 首先,我们创建了form对象,对其传递类似字典的request.GET对象。request.GET对象包含所有使用GET方法传递的查询变量。
  • 然后,form被传递给get_queryset_and_facets()方法,它通过包含两个元素的元组返回关联值,这两个元素分别为QuerySet和facets。
  • 当前的请求对象及所获取的查询集合被传递给get_page()方法,该方法返回页面对象。
  • 最后,我们创建一个上下文字典并渲染请求的响应。

我们也可以提供由HTTP POST请求所调用的 post()方法来进行相应的支持。

扩展知识...

可以看到,get() 和get_page()方法很通用,因此我们可以在core应用中创建带有这两个方法的FilterableListView通用类。然后,在需要使用可过滤列表的应用中,可以创建继承FilterableListView的视图类处理这种场景。这一继承类只需要定义form_class和template_name属性以及get_queryset_and_facets()方法。这种模板化和扩展性代表了视图类的主要好处。

相关内容

  • 过滤对象列表一节
  • 管理分页列表一节

添加Open Graph和Twitter Card数据

如果希望在社交网站上分享网站的内容,则至少应实现Open Graph和Twitter Card标签。这些元标签定义了网页如何在Facebook或Twitter信息流中显示:展示什么标题和描述,设置什么图片以及关联的URL。本小节中我们将为社交分享准备idea_detail.html模板。

准备工作

我们继续使用前面小节中的ideas应用。

如何实现...

按照如下步骤来完成本小节的学习:

  1. 确保Idea模板创建了图片字段及各个图片版本规格。参见使用CRUDL函数创建应用一节和上传图片一节了解更多信息。

  2. 准备好ideas详情视图。参见使用CRUDL函数创建应用一节了解如何添加。

  3. 将详情视图插入到URL配置中。相关配置参见使用CRUDL函数创建应用一节。

  4. 在具体环境的设置中,定义WEBSITE_URL和作为完整媒体文件URL的MEDIA_URL,如下例所示:

    # myproject/settings/dev.py
    from ._base import *
    
    DEBUG = True
    WEBSITE_URL = "http://127.0.0.1:8000" # without trailing slash 
    MEDIA_URL = f"{WEBSITE_URL}/media/"
    
  5. 在core应用中,创建返回设置中WEBSITE_URL变量的上下文处理器:

    # myproject/apps/core/context_processors.py
    from django.conf import settings
    
    def website_url(request):
      return {
        "WEBSITE_URL": settings.WEBSITE_URL,
      }
    
  6. 在设置中插入上下文处理器:

    # myproject/settings/_base.py
    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",
          ]
        } 
      }
    ]
    
  7. 创建模板文件idea_detail.html并添加如下内容:

    {# ideas/idea_detail.html #}
    {% extends "base.html" %} 
    {% load i18n %}
    
    {% block meta_tags %}
      <meta property="og:type" content="website" />
      <meta property="og:url" content="{{ WEBSITE_URL }}{{ request.path }}" />
      <meta property="og:title" content="{{ idea.translated_title }}" />
      {% if idea.picture_social %}
        <meta property="og:image" content= "{{ idea.picture_social.url }}" />
        <!-- Next tags are optional but recommended --> 
        <meta property="og:image:width" content="{{ idea.picture_social.width }}" /> 
        <meta property="og:image:height" content="{{ idea.picture_social.height }}" /> 
      {% endif %}
      <meta property="og:description" content= "{{ idea.translated_content }}" />
      <meta property="og:site_name" content="MyProject" />
      <meta property="og:locale" content="{{ LANGUAGE_CODE }}" />
    
      <meta name="twitter:card" content="summary_large_image">
      <meta name="twitter:site" content="@DjangoTricks">
      <meta name="twitter:creator" content="@archatas">
      <meta name="twitter:url" content="{{ WEBSITE_URL }}{{ request.path }}">
      <meta name="twitter:title" content="{{ idea.translated_title }}">
      <meta name="twitter:description" content="{{ idea.translated_content }}"> 
      {% if idea.picture_social %}
        <meta name="twitter:image" content= "{{ idea.picture_social.url }}">
      {% endif %}
    {% endblock %}
    
    {% block content %}
      <a href="{% url "ideas:idea_list" %}">
        {% trans "List of ideas" %}</a>
      <h1>
        {% blocktrans trimmed with title=idea.translated_title %} 
          Idea "{{ title }}"
        {% endblocktrans %}
      </h1>
      <img src="{{ idea.picture_large.url }}" alt="" /> 
      {{ idea.translated_content|linebreaks|urlize }} 
      <p>
        {% for category in idea.categories.all %} 
          <span class="badge badge-pill badge-info">
            {{ category.translated_title }}
          </span> 
        {% endfor %}
      </p>
      <a href="{% url 'ideas:change_idea' pk=idea.pk %}"
        class="btn btn-primary">{% trans "Change this idea" %}</a> 
      <a href="{% url 'ideas:delete_idea' pk=idea.pk %}"
        class="btn btn-danger">{% trans "Delete this idea" %}</a> 
    {% endblock %}
    

实现原理...

Open Graph标签是以og:打头特殊名称的元标签,Twitter card标签是以twitter:打头特殊名称的元标签。这些元标签定义了URL、标签、描述和当前页面的图片、站点名、作者和地理位置。在这里要提供完整路径,只提供后面的路径是不够的。

我们使用picture_social图片版本,它是针对社交网站的优化尺寸:1024 × 512 px。

可以通过https://developers.facebook.com/tools/debug/来验证Open Graph的实现情况。

通过https://cards-dev.twitter.com/validator可以验证Twitter Card的实现情况。

相关内容

  • 使用CRUDL函数创建应用一节
  • 上传图片一节
  • 添加schema.org用词一节

添加schema.org用词

搜索引擎优化SEO需要遵循语法的标记。但更进一步提升搜索引擎排名,根据schema.org的用词提供结构化数据是大有裨益的。很多来自谷歌、微软、Pinterest和Yandex等的应用都使用schema.org结构来创造丰富可扩展的体验,如在搜索结果在活动、电脑、作者等的特殊一致性卡片展示。

有一些编码,包括RDFa、Microdata和JSON-LD,可用于创建schema.org用词。本小节中,我们将以JSON-LD格式来为Idea模板准备结构化数据,这一格式是谷歌所推荐的。

准备工作

在项目的虚拟环境中安装django-json-ld包(并将其添加到requirements/_base.txt中):

(env)$ pip install django-json-ld==0.0.4

在设置中将django_json_ld添加到INSTALLED_APPS中:

# myproject/settings/_base.py
INSTALLED_APPS = [ 
  # other apps...
  "django_json_ld",
]

如何实现...

按照如下步骤来完成本小节的学习:

  1. 在Idea模型中使用如下内容添加structured_data属性:

    # myproject/apps/ideas/models.py
    from django.db import models
    from django.utils.translation import gettext_lazy as _
    
    from myproject.apps.core.models import ( 
      CreationModificationDateBase, UrlBase 
    )
    
    
    class Idea(CreationModificationDateBase, UrlBase):
      # attributes, fields, properties, and methods...
      @property
      def structured_data(self):
        from django.utils.translation import get_language
        
        lang_code = get_language()
        data = {
          "@type": "CreativeWork",
          "name": self.translated_title, 
          "description": self.translated_content, 
          "inLanguage": lang_code,
        }
        if self.author:
          data["author"] = {
            "@type": "Person",
            "name": self.author.get_full_name() or
            self.author.username, 
          }
        if self.picture:
          data["image"] = self.picture_social.url
        return data
    
  2. 修改idea_detail.html模板:

    {# ideas/idea_detail.html #}
    {% extends "base.html" %} 
    {% load i18n json_ld %}
    
    {% block meta_tags %}
      {# Open Graph and Twitter Card meta tags here... #}
    
      {% render_json_ld idea.structured_data %}
    {% endblock %}
    
    {% block content %}
      <a href="{% url "ideas:idea_list" %}">
        {% trans "List of ideas" %}</a>
      <h1>
        {% blocktrans trimmed with title=idea.translated_title %} 
          Idea "{{ title }}"
        {% endblocktrans %}
      </h1>
      <img src="{{ idea.picture_large.url }}" alt="" /> 
      {{ idea.translated_content|linebreaks|urlize }} 
      <p>
        {% for category in idea.categories.all %}
          <span class="badge badge-pill badge-info"> {{ category.translated_title }}</span>
        {% endfor %}
      </p>
      <a href="{% url 'ideas:change_idea' pk=idea.pk %}" class="btn btn-primary">{% trans "Change this idea" %}</a>
      <a href="{% url 'ideas:delete_idea' pk=idea.pk %}" class="btn btn-danger">{% trans "Delete this idea" %}</a>
    {% endblock %}
    

实现原理...

{% render_json_ld %}模板标签会像类似下面这样渲染script标签:

<script type=application/ld+json>{"@type": "CreativeWork", "author": {"@type": "Person", "name": "alan"}, "description": "Wouldn't it make sense to gather people with opposing opinions and try to solve mutual problems together?", "image": "http://127.0.0.1:8000/media/CACHE/images/ideas/2020/05/0080c405-9dfe-4b03-9769-b5f49149f064/922cbc290ac1421d7bc950c912678453.jpg", "inLanguage": "en", "name": "A reality show about people from opposing standpoints trying to solve mutual problems"}</script>

structured_data属性根据schema.org用词返回一个嵌套字典,这可由大部分主流搜索引擎所理解。

可通过查阅官方文档来决定对模型应用哪些用词。

相关内容

  • 第2章 模型和数据库结构创建模型mixin来处理元标签一节
  • 使用CRUDL函数创建应用一节
  • 上传图片一节
  • 添加Open Graph和Twitter Card数据一节

生成PDF文档

Django视图不止是能让我们创建HTML页面。还可以创建任意类型的文件。例如,在第4章 模板和JavaScript中的在JavaScript暴露设置一节中,我们视图的输出是JavaScript文件的形式而非HTML。也可以对发票、票务、收据、预订确认等创建PDF文件。本小节中,我们将展示如何为数据库中的每个创建生成传单。这里使用WeasyPrint库来用HTML模板生成PDF文档。

准备工作

WeasyPrint依赖于一些库,需要在电脑上进行安装。macOS中可以使用如下命令通过Homebrew来安装这些库:

$ brew install python3 cairo pango gdk-pixbuf libffi

然后,可以在项目的虚拟环境中安装WeasyPrint本尊。同样请在requirements/_base.txt中进行添加:

(env)$ pip install WeasyPrint==48

对于其它操作系统,请参照安装教程

另外我们将使用django-qr-code来生成快速链接回到网站的二维码。在虚拟环境中也对其进行安装(并在requirements/_base.txt中添加):

(env)$ pip install django-qr-code==1.0.0

在设置的INSTALLED_APPS中添加qr_code:

# myproject/settings/_base.py
INSTALLED_APPS = [ 
  # Django apps...
  "qr_code", 
]

如何实现...

按照如下步骤来完成本节的学习:

  1. 创建用于生成PDF文档的视图:

    # myproject/apps/ideas/views.py
    from django.shortcuts import get_object_or_404 
    from .models import Idea
    
    def idea_handout_pdf(request, pk):
      from django.template.loader import render_to_string 
      from django.utils.timezone import now as timezone_now 
      from django.utils.text import slugify
      from django.http import HttpResponse
    
      from weasyprint import HTML
      from weasyprint.fonts import FontConfiguration
    
      idea = get_object_or_404(Idea, pk=pk) 
      context = {"idea": idea}
      html = render_to_string(
        "ideas/idea_handout_pdf.html", context 
      )
    
      response = HttpResponse(content_type="application/pdf") 
      response[
        "Content-Disposition"
      ] = "inline; filename={date}-{name}-handout.pdf".format(
        date=timezone_now().strftime("%Y-%m-%d"),
        name=slugify(idea.translated_title), 
      )
    
      font_config = FontConfiguration() 
      HTML(string=html).write_pdf(
        response, font_config=font_config 
      )
      
      return response
    
  2. 将这个视图插入到URL配置中:

    # myproject/apps/ideas/urls.py
    from django.urls import path
    
    from .views import idea_handout_pdf 
    urlpatterns = [
      # URL configurations... 
      path("<uuid:pk>/handout/", idea_handout_pdf, name="idea_handout"),
    ]
    
  3. 为PDF文档创建一个模板:

    {# ideas/idea_handout_pdf.html #}
    {% extends "base_pdf.html" %} 
    {% load i18n qr_code %}
    
    {% block content %}
      <h1 class="h3">{% trans "Handout" %}</h1>
      <h2 class="h1">{{ idea.translated_title }}</h2> 
      <img src="{{ idea.picture_large.url }}" alt="" class="img-responsive w-100" />
      <div class="my-3">{{ idea.translated_content|linebreaks|urlize }}</div>
      <p>
        {% for category in idea.categories.all %} 
          <span class="badge badge-pill badge-info">
          {{ category.translated_title }}</span> 
        {% endfor %}
      </p>
      <h4>{% trans "See more information online:" %}</h4>
      {% qr_from_text idea.get_url size=20 border=0 as svg_code %} 
      <img alt="" src="data:image/svg+xml,
        {{ svg_code|urlencode }}" />
      <p class="mt-3 text-break">{{ idea.get_url }}</p>
    {% endblock %}
    
  4. 另外,创建base_pdf.html模板:

    {# base_pdf.html #}
    <!doctype html>
    {% load i18n static %} 
    <html lang="en"> 
    <head>
      <!-- Required meta tags -->
      <meta charset="utf-8">
      <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
    
      <!-- Bootstrap CSS --> 
      <link rel="stylesheet"
        href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css"
        integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous">
      <title>{% trans "Hello, World!" %}</title>
      
      <style>
      @page {
        size: "A4";
        margin: 2.5cm 1.5cm 3.5cm 1.5cm; 
      }
      footer {
        position: fixed; 
        bottom: -2.5cm; 
        width: 100%; 
        text-align: center; 
        font-size: 10pt;
      }
      footer img {
        height: 1.5cm; 
      }
      </style>
    
      {% block meta_tags %}{% endblock %}
    </head>
    <body>
      <main class="container">
        {% block content %}
        {% endblock %}
      </main>
      <footer>
      <img alt="" src="data:image/svg+xml,
        {# url-encoded SVG logo goes here #}" /> 
        <br />
        {% trans "Printed from MyProject" %}
      </footer>
    </body>
    </html>
    

实现原理...

WeasyPrint生成可供打印、像素精致的文档。本例中可在展示时分发给观众的传递类似下面这样:

WeasyPrint生成 PDF

译者注: 实际测试书中代码所添加的 BootStrap 样式不会生效,而因远程文件会报 ssl 相关错误,在idea_handout_pdf我使用的本地 css (线上下载的 BootStrap 4 css 代码)的方式生成的 PDF,代码如下:

css = CSS(
         settings.WEBSITE_URL + settings.STATIC_URL + 'site/css/bootstrap.min.css',
     )
 HTML(string=html).write_pdf(
     response,
     font_config=font_config,
     stylesheets=[css]
 )

测试时发现 font_config 配置并不会产生什么影响,根据官方文档它主要用于兼容@font-face,总体测试发现 WeasyPrint与 BootStrap 的兼容性存在一定问题,如上图中可以看到使用 BootStrap 4时数字的显示存在问题,使用 BootStrap 3时则无此问题,调试时发现 HTML代码显示也是正常的,如果有知道问题的读者欢迎在评论区中告知。

文档的布局由HTML 和CSS进行定义。WeasyPrint有其自身的渲染引擎。阅读官方文档获取更多支持功能。

可以使用保存为矢量图而非位图的SVG图像,这样传单会更为精致。当前还不支持行内SVG,但可以使用带有数据源或外部URL的标签。本例中,我们对二维码及底部的logo使用了SVG图像。

我们来过一下视图中的代码。通过所选的idea来作为html字符串来渲染idea_handout_pdf.html模板。然后,我们创建PDF内容类型的HttpResponse对象,文件名由当前日期转为链接的idea的标题所构成。然后,我们通过HTML创建了一个WeasyPrint的HTML对象,并像写入文件中那样将其写入到响应中。此外,我们使用FontConfiguration对象,它让我们可以在布局的CSS配置中添加并使用网页字体。最后,我们返回响应对象。

相关内容

通过Haystack和Whoosh实现多语言搜索

内容网站的一个主要功能是全文本搜索。Haystack是一个支持Solr、Elasticsearch、Whoosh和Xapian搜索引擎的模块化搜索API。对于项目中希望可进行搜索的模型,需要定义一个可通过模型读取文本信息并将其放到后台中的索引。本节中,我们将学习如何通过Haystack和基于Python的搜索语言来为多语言网站配置搜索。

准备工作

我们使用此前定义的categories和ideas应用。

确保在虚拟环境中安装了django-haystack和Whoosh(并将它们添加到requirements/_base.txt中):

(env)$ pip install django-haystack==2.8.1
(env)$ pip install Whoosh==2.7.4

如何实现...

执行如下步骤来通过Haystack和Whoosh来设置多语种搜索:

  1. 创建一个search应用,其中包含MultilingualWhooshEngine以及对ideas的搜索索引。搜索引擎放到multilingual_whoosh_backend.py文件:

    # myproject/apps/search/multilingual_whoosh_backend.py
    from django.conf import settings
    from django.utils import translation
    from haystack.backends.whoosh_backend import (
      WhooshSearchBackend,
      WhooshSearchQuery,
      WhooshEngine,
    )
    from haystack import connections
    from haystack.constants import DEFAULT_ALIAS
    
    
    class MultilingualWhooshSearchBackend(WhooshSearchBackend): 
      def update(self, index, iterable, commit=True, language_specific=False):
        if not language_specific and self.connection_alias == "default":
          current_language = (translation.get_language() or
          settings.LANGUAGE_CODE)[:2]
          for lang_code, lang_name in settings.LANGUAGES:
            lang_code_underscored = lang_code.replace("-", "_") 
            using = f"default_{lang_code_underscored}" 
            translation.activate(lang_code)
            backend = connections[using].get_backend()
            backend.update(index, iterable, commit, language_specific=True)
            translation.activate(current_language) 
        elif language_specific:
          super().update(index, iterable, commit)
    
    
    class MultilingualWhooshSearchQuery(WhooshSearchQuery): 
      def __init__(self, using=DEFAULT_ALIAS):
        lang_code_underscored = translation.get_language().replace("-", "_") 
        using = f"default_{lang_code_underscored}" 
        super().__init__(using=using)
    
    
    class MultilingualWhooshEngine(WhooshEngine): 
      backend = MultilingualWhooshSearchBackend 
      query = MultilingualWhooshSearchQuery
    
  2. 创建搜索索引如下:

    # myproject/apps/search/search_indexes.py
    from haystack import indexes
    
    from myproject.apps.ideas.models import Idea
    
    
    class IdeaIndex(indexes.SearchIndex, indexes.Indexable): 
      text = indexes.CharField(document=True)
    
      def get_model(self):
        return Idea
    
      def index_queryset(self, using=None): 
        """
        Used when the entire index for model is updated. 
        """
        return self.get_model().objects.all()
             
      def prepare_text(self, idea):
        """
        Called for each language / backend 
        """
        fields = [
          idea.translated_title, idea.translated_content 
        ]
        fields += [ 
          category.translated_title
          for category in idea.categories.all() 
        ]
        return "\n".join(fields)
    
  3. 使用MultilingualWhooshEngine配置设置文件:

    # myproject/settings/_base.py
    import os
    BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(
       os.path.abspath(__file__) 
    )))
    
    #...
    
    INSTALLED_APPS = [ 
       # contributed
       #...
       # third-party
       #...
       "haystack",
       # local 
       "myproject.apps.core", 
       "myproject.apps.categories", 
       "myproject.apps.ideas", 
       "myproject.apps.search",
    ]
    
    LANGUAGE_CODE = "en"
    
    # All official languages of European Union 
    LANGUAGES = [
       ("bg", "Bulgarian"),
       ("hr", "Croatian"),
       ("cs", "Czech"),
       ("da", "Danish"),
       ("nl", "Dutch"),
       ("en", "English"),
       ("et", "Estonian"),
       ("fi", "Finnish"),
       ("fr", "French"),
       ("de", "German"),
       ("el", "Greek"),
       ("hu", "Hungarian"),
       ("ga", "Irish"),
       ("it", "Italian"),
       ("lv", "Latvian"),
       ("lt", "Lithuanian"),
       ("mt", "Maltese"),
       ("pl", "Polish"),
       ("pt", "Portuguese"),
       ("ro", "Romanian"),
       ("sk", "Slovak"),
       ("sl", "Slovene"),
       ("es", "Spanish"),
       ("sv", "Swedish"),
    ]
    
    HAYSTACK_CONNECTIONS = {}
    for lang_code, lang_name in LANGUAGES:
       lang_code_underscored = lang_code.replace("-", "_") 
       HAYSTACK_CONNECTIONS[f"default_{lang_code_underscored}"] = { 
          "ENGINE": "myproject.apps.search.multilingual_whoosh_backend.MultilingualWhooshEngine",
          "PATH": os.path.join(BASE_DIR, "tmp", f"whoosh_index_{lang_code_underscored}"),
       }
    lang_code_underscored = LANGUAGE_CODE.replace("-", "_") 
    HAYSTACK_CONNECTIONS["default"] = HAYSTACK_CONNECTIONS[ 
       f"default_{lang_code_underscored}"
    ]
    
  4. 在URL规则里添加路径:

    # 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")),
       path("search/", include("haystack.urls")),
    )
    urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
    urlpatterns += static("/media/", document_root=settings.MEDIA_ROOT)
    
  5. 需要一个针对搜索表单和搜索结果的模板,如下:

    {# search/search.html #}
    {% extends "base.html" %} 
    {% load i18n %}
    
    {% block sidebar %}
      <form method="get" action="{{ request.path }}">
        <div class="well clearfix"> 
          {{ form.as_p }}
          <p class="pull-right">
            <button type="submit" class="btn btn-primary">
              {% trans "Search" %}</button>
          </p>
         </div>
      </form>
     {% endblock %}
     
    {% block main %}
      {% if query %}
        <h1>{% trans "Search Results" %}</h1>
    
        {% for result in page.object_list %} 
          {% with idea=result.object %}
            <a href="{{ idea.get_url_path }}" class="d-block my-3">
              <div class="card">
                <img src="{{ idea.picture_thumbnail.url }}" alt="" />
                <div class="card-body">
                  <p class="card-text">
                  {{ idea.translated_title }}</p>
                </div>
                </div>
            </a>
          {% endwith %}
        {% empty %}
          <p>{% trans "No results found." %}</p>
        {% endfor %}
        
        {% include "misc/includes/pagination.html" with object_list=page %}
      {% endif %}
    {% endblock %}
    
  6. 管理分页列表一节中那样在misc/includes/pagination.html中添加分页模板。

  7. 调用rebuild_index管理命令来索引数据库中的数据并准备用于全文本搜索:

    (env)$ python manage.py rebuild_index --noinput
    

译者注: Haystack 最新版本与 Django 3.0在测试时发现存在兼容性的问题,主要有

  1. ImportError: cannot import name 'six' from 'django.utils'
  2. ImportError: cannot import name 'python_2_unicode_compatible' from 'django.utils.encoding'

这两个错误需要在源码层面进行处理:

修改原导入为 import six 及 from six import python_2_unicode_compatible或将对应文件代码拷贝到 django.utils 中

实现原理...

MultilingualWhooshEngine中指定了两个自定义属性:

  • backend指向MultilingualWhooshSearchBackend,确保LANGUAGES设置中给定每种语言对应数据都会被索引,并放到HAYSTACK_CONNECTIONS所定义的相关联Haystack索引位置。
  • query引用MultilingualWhooshSearchQuery,后者的职责是确保在搜索关键词时,会使用当前语言所对应的Haystack连接。

每个索引都有一个text字段,这里面存储模型中具体语言的全文本。索引的模型由get_model()所决定,index_queryset()定义应索引哪个查询集合,其中搜索的内容在prepare_text()方法中按新行分隔进行定义。

模板中我们集成了一些Bootstrap 4的元素来对表单进行开箱即用的渲染。这可以通过本章此前的通过django-crispy-forms创建表单布局一节中所介绍的方法进行完善。

最终的搜索页面表单在侧边栏中、搜索结果位于主栏中,类似下面这样:

Django 3 Haystack搜索结果

定期更新搜索索引最简单的方式是通过定时任务(比如每晚)调用rebuild_index management命令。了解这部分知识,请参见第13章 维护中的设置cron job执行定时任务一节。

相关内容

  • 通过django-crispy-forms创建表单布局一节
  • 管理分页列表一节
  • 第13章 维护中的设置cron job执行定时任务一节

通过Elasticsearch DSL实现多语言搜索

Haystack结合Whoosh一种很稳定的搜索机制 ,只需要用一些Python模块,但要实现更好的性能,我们推荐使用Elasticsearch。本节中,我们将展示如何使用它来实现多语种的搜索。

准备工作

首先安装Elasticsearch服务端。在macOS中可以使用Homebrew来进行安装:

$ brew install elasticsearch

在写本书时,Homebrew中最新的Elasticsearch稳定版本为6.8.2。

在虚拟环境中安装django-elasticsearch-dsl(并将其添加到requirements/_base.txt中):

(env)$ pip install django-elasticsearch-dsl==6.4.1

ℹ️注意安装版本相匹配的django-elasticsearch-dsl非常重要。否则在尝试连接Elasticsearch服务或构建索引时会报错。版本兼容性表请见https://github.com/sabricot/django-elasticsearch-dsl。

译者注: Elasticsearch 可通过多种方式,下载源码亦可直接启动,Alan使用 Docker 的方式进行安装, documents.py 中title_bg等配置取决于多语言所选择的方案。另在 ES 7中 StringField 可修改为 KeywordField

docker pull docker.elastic.co/elasticsearch/elasticsearch:7.7.0
docker run -p 9200:9200 -p 9300:9300 -e "discovery.type=single-node" docker.elastic.co/elasticsearch/elasticsearch:7.7.0
pip install django-elasticsearch-dsl==7.1.1

如何实现...

我们来通过如下步骤借助 Elasticsearch DSL设置多语言搜索:

  1. 修改配置文件、添加django_elasticsearch_dsl到INSTALLED_APPS中,并配置ELASTICSEARCH_DSL如下:

    # myproject/settings/_base.py
    INSTALLED_APPS = [ 
      # other apps...
      "django_elasticsearch_dsl", 
    ]
    
    ELASTICSEARCH_DSL={
      'default': {
        'hosts': 'localhost:9200'
      },
    }
    
  2. 在ideas应用中,创建documents.py文件,在其中添加IdeaDocument来进行idea搜索的索引,如下:

    # myproject/apps/ideas/documents.py
    from django.conf import settings
    from django.utils.translation import get_language, activate 
    from django.db import models
    
    from django_elasticsearch_dsl import fields 
    from django_elasticsearch_dsl.documents import (
      Document,
      model_field_class_to_field_class,
    )
    from django_elasticsearch_dsl.registries import registry
    
    from myproject.apps.categories.models import Category 
    from .models import Idea
    
    def _get_url_path(instance, language): 
      current_language = get_language() 
      activate(language)
      url_path = instance.get_url_path() 
      activate(current_language)
      return url_path
    
    
    @registry.register_document 
    class IdeaDocument(Document):
      author = fields.NestedField( 
        properties={
          "first_name": fields.StringField(), 
          "last_name": fields.StringField(), 
          "username": fields.StringField(), 
          "pk": fields.IntegerField(),
        },
        include_in_root=True, 
      )
      title_bg = fields.StringField()
      title_hr = fields.StringField()
      # other title_* fields for each language in the LANGUAGES setting...
      content_bg = fields.StringField()
      content_hr = fields.StringField()
      # other content_* fields for each language in the LANGUAGES setting...
    
      picture_thumbnail_url = fields.StringField()
      categories = fields.NestedField( 
        properties=dict(
          pk=fields.IntegerField(), 
          title_bg=fields.StringField(), 
          title_hr=fields.StringField(),
          # other title_* definitions for each language in the LANGUAGES setting... 
        ),
        include_in_root=True,
      )
      url_path_bg = fields.StringField()
      url_path_hr = fields.StringField()
      # other url_path_* fields for each language in the LANGUAGES setting...
    
      class Index:
        name = "ideas"
        settings = {"number_of_shards": 1, "number_of_replicas": 0}
    
      class Django: 
        model = Idea
        # The fields of the model you want to be indexed in Elasticsearch
        fields = ["uuid", "rating"] 
        related_models = [Category]
        
      def get_instances_from_related(self, related_instance):
        if isinstance(related_instance, Category):
          category = related_instance
          return category.category_ideas.all()
    
  3. 在IdeaDocument中添加prepare_*方法来准备索引的数据:

    def prepare(self, instance):
      lang_code_underscored = settings.LANGUAGE_CODE.replace("-", "_")
      setattr(instance, f"title_{lang_code_underscored}", instance.title)
      setattr(instance, f"content_{lang_code_underscored}", instance.content) 
      setattr(
        instance, 
        f"url_path_{lang_code_underscored}", 
        _get_url_path(instance=instance,
        language=settings.LANGUAGE_CODE), 
      )
      for lang_code, lang_name in settings.LANGUAGES_EXCEPT_THE_DEFAULT: 
        lang_code_underscored = lang_code.replace("-", "_") 
        setattr(instance, f"title_{lang_code_underscored}", "")
        setattr(instance, f"content_{lang_code_underscored}", "")
        translations = instance.translations.filter(language=lang_code).first() 
        if translations:
          setattr(instance, f"title_{lang_code_underscored}", translations.title)
          setattr(
            instance, f"content_{lang_code_underscored}",
            translations.content 
          )
          setattr(
            instance,
            f"url_path_{lang_code_underscored}", 
            _get_url_path(instance=instance,
            language=lang_code),
          )
      data = super().prepare(instance=instance) 
      return data
    
    def prepare_picture_thumbnail_url(self, instance): 
      if not instance.picture:
        return ""
      return instance.picture_thumbnail.url
    
    def prepare_author(self, instance): 
      author = instance.author
      if not author:
        return [] 
      author_dict = {
        "pk": author.pk,
        "first_name": author.first_name, 
        "last_name": author.last_name, 
        "username": author.username,
      }
      return [author_dict]
    
    def prepare_categories(self, instance): 
      categories = []
      for category in instance.categories.all(): 
        category_dict = {"pk": category.pk} 
        lang_code_underscored = settings.LANGUAGE_CODE.replace("-", "_") 
        category_dict[f"title_{lang_code_underscored}"] = category.title
        for lang_code, lang_name in settings.LANGUAGES_EXCEPT_THE_DEFAULT: 
          lang_code_underscored = lang_code.replace("-", "_") 
          category_dict[f"title_{lang_code_underscored}"] = "" 
          translations = category.translations.filter(language= lang_code).first()
          if translations:
            category_dict[f"title_{lang_code_underscored}"] = translations.title 
        categories.append(category_dict)
      return categories
    
  4. 对IdeaDocument添加一些属性和方法,从索引文档中返回翻译内容:

    @property
    def translated_title(self):
      lang_code_underscored = get_language().replace("-", "_") 
      return getattr(self, f"title_{lang_code_underscored}", "")
    
    @property
    def translated_content(self):
      lang_code_underscored = get_language().replace("-", "_") 
      return getattr(self, f"content_{lang_code_underscored}", "")
    
    def get_url_path(self):
      lang_code_underscored = get_language().replace("-", "_") 
      return getattr(self, f"url_path_{lang_code_underscored}", "")
    
    def get_categories(self):
      lang_code_underscored = get_language().replace("-", "_") 
      return [
        dict( 
          translated_title=category_dict[f"title_{lang_code_underscored}"],
          **category_dict,
        )
        for category_dict in self.categories 
      ]
    
  5. 在documents.py文件中还要做的一件事是对UUIDField打猴子补丁,因默认Django Elasticsearch DSL不支持这种字段。只需要在导入区块之后添加如下代码即可:

    model_field_class_to_field_class[models.UUIDField] = fields.TextField
    
  6. 在ideas应用的forms.py中创建IdeaSearchForm:

    # myproject/apps/ideas/forms.py
    from django import forms
    from django.utils.translation import ugettext_lazy as _
    
    from crispy_forms import helper, layout
    
    
    class IdeaSearchForm(forms.Form):
      q = forms.CharField(label=_("Search for"), required=False)
    
      def __init__(self, request, *args, **kwargs): 
        self.request = request 
        super().__init__(*args, **kwargs)
      
        self.helper = helper.FormHelper() 
        self.helper.form_action = self.request.path 
        self.helper.form_method = "GET" 
        self.helper.layout = layout.Layout(
          layout.Field("q", css_class="input-block-level"),
          layout.Submit("search", _("Search")), 
        )
    
  7. 添加使用Elasticsearch进行搜索的视图:

    # myproject/apps/ideas/views.py
    from django.shortcuts import render
    from django.conf import settings
    from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator
    from django.utils.functional import LazyObject
    
    from .forms import IdeaSearchForm
    
    PAGE_SIZE = getattr(settings, "PAGE_SIZE", 24)
    
    
    class SearchResults(LazyObject):
      def __init__(self, search_object):
        self._wrapped = search_object
    
      def __len__(self):
        return self._wrapped.count()
      
      def __getitem__(self, index): 
        search_results = self._wrapped[index] 
        if isinstance(index, slice):
          search_results = list(search_results) 
        return search_results
    
    def search_with_elasticsearch(request): 
      from .documents import IdeaDocument 
      from elasticsearch_dsl.query import Q
      
      form = IdeaSearchForm(request, data=request.GET)
      
      search = IdeaDocument.search()
      
      if form.is_valid():
        value = form.cleaned_data["q"]
        lang_code_underscored = request.LANGUAGE_CODE.replace("-", "_")
        search = search.query(
          Q("match_phrase", 
            **{f"title_{lang_code_underscored}":
            value})
          | Q("match_phrase", **{f"content_{
              lang_code_underscored}": value})
          | Q(
              "nested",
              path="categories", 
              query=Q(
                "match_phrase",
                **{f"categories__title_{
                lang_code_underscored}": value},
              ),
            )
        )
    
        search_results = SearchResults(search)
      
        paginator = Paginator(search_results, PAGE_SIZE) 
        page_number = request.GET.get("page")
        try:
          page = paginator.page(page_number) 
        except PageNotAnInteger:
          # If page is not an integer, show first page.
          page = paginator.page(1) 
        except EmptyPage:
          # If page is out of range, show last existing page. 
          page = paginator.page(paginator.num_pages)
      
        context = {"form": form, "object_list": page}
        return render(request, "ideas/idea_search.html", context)
    
  8. 对搜索表单和搜索结果创建idea_search.html模板:

    {# ideas/idea_search.html #}
    {% extends "base.html" %}
    {% load i18n crispy_forms_tags %}
    
    {% block sidebar %}
      {% crispy form %}
    {% endblock %}
    
    {% block main %}
      <h1>{% trans "Search Results" %}</h1>
      {% if object_list %}
        {% for idea in object_list %}
          <a href="{{ idea.get_url_path }}" class="d-block my-3">
            <div class="card">
              <img src="{{ idea.picture_thumbnail_url }}" alt="" />
              <div class="card-body">
                <p class="card-text">{{ idea.translated_title }}</p>
              </div>
            </div>
          </a>
        {% endfor %}
        {% include "misc/includes/pagination.html" %} 
      {% else %}
        <p>{% trans "No ideas found." %}</p> 
      {% endif %}
    {% endblock %}
    
  9. 管理分页列表一节中那样在 misc/includes/pagination.html中添加分页模板。

  10. 调用search_index --rebuild管理命令来索引数据库数据并准备用于搜索的全文本:

    (env)$ python manage.py search_index --rebuild
    

实现原理...

Django Elasticsearch DSL文档类似于模型表单。这里可以定义存储模型中的哪个字段供稍后在搜索查询时使用。在我们的IdeaDocument示例中,保存了UUID、评分、 作者、分类、标题、内容和所有语言的URL路径以及图片缩略图URL。Index类定义这个文档的Elasticsearch索引设置。Django类定义从何处获取索引字段。有一个related_models设置在哪个模型修改后更新这个索引。本例中为Category模型。注意使用django-elasticsearch-dsl时,不论何时保存模型都会自动更新。这是通过信号来实现的。

get_instances_from_related()方法表明在修改Category实例时如何获取Idea模型实例。

IdeaDocument的prepare() 和 prepare_*()方法表明从何处获取数据以及对指定字段保存数据。例如,在语言字段等于lt时我们从IdeaTranslations模型的标题字段读取title_lt数据。

IdeaDocument最后面的那些属性和方法用于按当前语言从索引中获取信息。

然后我们来看一个搜索表单视图。表单中有一个名为q的查询字段。在进行提交时,我们在当前语言的标题、内容或分类标题字段中搜索所查询关键词。然后,我们使用懒加载类SearchResults封装搜索结果,这样就可以使用默认的Django分页器。

视图的模板侧边栏中有表单,主栏目中为搜索结果,类似下面这样:

Django 3 Elasticsearch搜索结果

相关内容

  • 使用CRUDL函数创建应用一节
  • 通过Haystack和Whoosh实现多语言搜索一节
  • 通过django-crispy-forms创建表单布局一节
  • 管理分页列表一节