Skip to content

Latest commit

 

History

History
965 lines (654 loc) · 36 KB

08.md

File metadata and controls

965 lines (654 loc) · 36 KB

第8章 层级结构

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

本章中包含如下小节:

  • 使用django-mptt创建分类层级
  • 使用django-mptt-admin创建分类后台界面
  • 使用django-mptt在模板中渲染分类
  • 通过django-mptt使用单选项在表单中选取单个分类
  • 通过django-mptt使用复选框列表在表单中选取多个分类
  • 通过django-treebeard创建层级分类
  • 通过django-treebeard创建基本分类后台界面

引言

无论你是在构建自己的博客、评论插件或分类系统,总会碰到需要在数据库中进行层级结构保存的问题。虽然在关联型数据库(如MySQL和PostgreSQL)中数据表是平级的,却有一种快速有效存储层级结构的方式。称为预排序遍历树(MPTT)。MPTT让我们可以不用对数据库进行递归调用就可读取树状结构。

首先,我们来熟悉一下树状结构的用词。树状数据结构是一种节点的嵌套集合,从根节点开始并对子节点进行引用。有很多限制:例如,节点不应引用回去产生循环引用,也不应重复引用。以下是其它应当知道的用词:

  • 父节点是有子节点引用的任意节点。
  • 后代节点是可通过由父节点对子节点进行遍历而访问到的节点。因此一个节点的后代包含其子节点以及子节点的子节点等等。
  • 祖先节点是可通过由子节点对父节点进行遍历而访问到的节点。因此一个节点的祖先包含其父节点以及父节点的父节点等等。
  • 兄弟节点是具有相同父节点的节点。
  • 叶子节点是没有子节点的节点。

下面来讲解下MPTT的原理。设置一下横向平铺树结构,根节点放在顶端。树中的每个节点有左值和右值。把它们想象为节点左右侧的左右把手。然后你逆时针从根节点开始行走(遍历),交对左值和右值进行数字标记:1、2、3,依此类推。类似下面的图表这样:

横向平铺树结构

在层级结构的数据表中,每个节点拥有有标题、左值和右值。

此时,如果希望获取左值为2、右值为11的B节点的子树,需要选取所有左值位于2和11之间的节点。即为 C、D、E和F。

要获取左值为5、右值为10的 D 节点的祖先节点,需要选取所有左值小于5、右值大于10的节点。即B和A。

获取一个节点的后代节点数量,可以使用如下公式:

$$descendants = (right - left - 1) / 2$$

因此B节点的后代节点数量可以通过如下公式来进行计算:

$$(11 - 2 - 1) / 2 = 4$$

如果想要将E节点添加到C节点,我们需要更新其第一个共有祖先节点 B下节点的左右值。这样 C 节点的左值依然是3;E 节点的左值为4、右值为5;C 节点的右值变成了6;D 节点的左值变成了7;F 节点的左值依然是8;其它均保持不变。

类似地在MPTT中还有树相关的运算。在项目中靠自己管理所有层级结构会极为复杂。所幸有一个名为django-mptt 的Django应用长期用于处理这些算法并提供有直接的 API 供处理树状结构来使用。另一个应用django-treebeard也在django CMS 3.1中替换了MPTT之后被广泛使用和测试,成为一个有力的替代应用。本章中,我们将学习如何使用这些帮助应用。

技术要求

运行本章的代码要求安装最新稳定版的Python 3、MySQL或PostgreSQL数据库以及通过虚拟环境创建的Django项目。

可在GitHub仓库的Chapter08目录中查看本章的代码。

使用django-mptt创建分类层级

为讲解如何处理MPTT,我们对第3章 表单和视图中的ideas应用做进一步构建。我们会将分类修改为层级Category模型并更新Idea模型来建立与分类的多对多关联。你也可以从头创建该应用,仅使用这里所展示的内容从头实现一个非常基础版本的Idea模型。

准备工作

执行如下步骤:

  1. 使用如下命令在虚拟环境中安装django-mptt:
(env)$ pip install django-mptt==0.10.0
  1. 如尚未创建请创建categories和ideas应用。将这些应用以及mptt添加到配置文件的INSTALLED_APPS中,如下所示:
# myproject/settings/_base.py 
INSTALLED_APPS = [
  #...
  "mptt",
  #... 
  "myproject.apps.categories", 
  "myproject.apps.ideas",
]

如何实现...

我们将创建一个层级Category模型并将其绑定到Idea模型,即与分类形成多对多关联,如下:

  1. 打开categories应用中的models.py文件并添加继承mptt.models.MPTTModel 和第2章 模型和数据库结构中所定义的CreationModificationDateBase。除mixin中的字段外,Category需要有一个TreeForeignKey类型的parent字段和title字段:
# myproject/apps/ideas/models.py
from django.db import models
from django.utils.translation import ugettext_lazy as _ 
from mptt.models import MPTTModel
from mptt.fields import TreeForeignKey

from myproject.apps.core.models import CreationModificationDateBase

class Category(MPTTModel, CreationModificationDateBase): 
  parent = TreeForeignKey(
    "self", on_delete=models.CASCADE,
    blank=True, null=True, related_name="children"
  )
  title = models.CharField(_("Title"), max_length=200)

  class Meta:
    ordering = ["tree_id", "lft"]
    verbose_name = _("Category") 
    verbose_name_plural = _("Categories")

  class MPTTMeta:
    order_insertion_by = ["title"]

  def __str__(self): 
    return self.title
  1. 更新Idea模型来包含TreeManyToManyField类型的categories字段:
# myproject/apps/ideas/models.py
from django.utils.translation import gettext_lazy as _

from mptt.fields import TreeManyToManyField

from myproject.apps.core.models import CreationModificationDateBase, UrlBase

class Idea(CreationModificationDateBase, UrlBase): 
  #...
  categories = TreeManyToManyField( 
    "categories.Category", 
    verbose_name=_("Categories"), 
    related_name="category_ideas",
  )
  1. 通过迁移和运行来更新数据库:
(env)$ python manage.py makemigrations 
(env)$ python manage.py migrate

实现原理...

MPTTModel mixin会对Category模型添加tree_id、lft、rght和level字段:

  • tree_id字段让我们可以在数据表中有多个树级结构。事实上每个根分类都保存在单独的树上。
  • lft和rght字段存放用于MPTT算法中的左值和右值。
  • level存储节点在树中的深度。根节点的level为0。

通过MPTT中的元信息选项order_insertion_by,我们保证了在新增分类时会按标题的字母排序。

除新字段外,MPTTModel mixin添加了类似使用JavaScript导航DOM元素的方法来在树状结构之间导航。这些方法如下:

  • 如果希望访问分类的祖先节点,使用下面的代码。这里ascending参数定义了读取节点的方向(默认值为False),include_self参数定义是否在QuerySet中包含当前分类(默认值为False)
ancestor_categories = category.get_ancestors( 
  ascending=False,
  include_self=False, 
)
  • 使用如下代码来仅获取根分类:
root = category.get_root()
  • 如果只想要获取分类的直接子类,合适如下代码:
children = category.get_children()
  • 使用下面的代码来获取一个分类的所有后代。这里include_self参数同样用于定义是否在QuerySet中包含当前分类:
descendants = category.get_descendants(include_self=False)
  • 如果想要获取后代的数量而又不对数据库进行查询,使用如下代码:
descendants_count = category.get_descendant_count()
  • 使用以下方法来获取所有兄弟节点:
siblings = category.get_siblings(include_self=False)
> ℹ️ 根分类被视作所有其它根分类的子节点。
  • 使用如下方法来只获取前后兄弟节点:
previous_sibling = category.get_previous_sibling() 
next_sibling = category.get_next_sibling()
  • 还有用于查看分类是否为根节点、子节点或叶子节点的方法,如下:
category.is_root_node() 
category.is_child_node() 
category.is_leaf_node()

所有这些方法均可在视图、模板或管理命令中使用。如果想要操作树状结构,还可以使用insert_at() 和 move_to()方法。可以阅读https://django-mptt.readthedocs.io/en/stable/models.html了解更多有关这些方法及树管理方法的内容。

上例的模型中,我们使用了TreeForeignKey和TreeManyToManyField。它们类似ForeignKey和ManyToManyField,不同点在于在后台界面中会以缩进的层级来显示各选项。

同时注意看Category模型的Meta类,我们先以tree_id然后以lft值对分类进行排序,来让分类在树状结构自然地展示出来。

相关内容

使用django-mptt-admin创建分类后台界面

django-mptt应用内置一个简易模型后台mixin,让我们可以创建一个树状结构并以缩进展示。要对树进行重新排序,需要自己创建这一功能或使用第三方解决方案。django-mptt-admin可以让我们对层级模型创建一个可拖拽的后台。下面在本节进行学习。

准备工作

首先按照前面使用django-mptt创建分类层级一节中讲解的方法配置categories应用。然后需要按照如下步骤来安装django-mptt-admin应用:

  1. 使用如下命令在虚拟环境中安装该应用:
(env)$ pip install django-mptt-admin==0.7.2
  1. 在配置的INSTALLED_APPS中添加该应用,如下:
# myproject/settings/_base.py 
INSTALLED_APPS = [
  #...
  "mptt", 
  "django_mptt_admin",
]
  1. 确保可在项目中访问django-mptt-admin 的静态文件:
(env)$ python manage.py collectstatic

如何实现...

创建admin.py文件,在其中对Category模型定义后台界面。它将继承DjangoMpttAdmin而不是admin.ModelAdmin,如下:

# myproject/apps/categories/admin.py
from django.contrib import admin
from django_mptt_admin.admin import DjangoMpttAdmin

from .models import Category

@admin.register(Category)
class CategoryAdmin(DjangoMpttAdmin):
  list_display = ["title", "created", "modified"] 
  list_filter = ["created"]

实现原理...

该分类的后台界面有两种模式:树状视图和网格视图。树状视图类似下图:

TODO

树状视图使用jqTree jQuery库来进行节点操作。可以展开及收起分类来获取更好的总览效果。可以在列表视图中拖拽标题来重新排序或修改依赖关系。在重新排序时,用户界面(UI)类似下图:

TODO

注意任何常规列表相关的配置,如list_display或list_filter会在树状视图中被忽略掉。同时,由order_insertion_by meta所控制的排序会被手动排序所覆盖。

如果希望过滤分类、按具体字段排序或应用后台动作,可以切换为网格视图,它显示默认的分类修改列表,如下图所示:

TODO

相关内容

  • 使用django-mptt创建分类层级一节
  • 通过django-treebeard创建基本分类后台界面一节

使用django-mptt在模板中渲染分类

在应用内创建好分类后,需要在模板中按层级显示这些分类。最简单的方式是使用MPTT树,在使用django-mptt创建分类层级一节中进行过表述,使用的是django-mptt 应用中的{% recursetree %} 模板标签。本节中将演示如何实现。

准备工作

确保已有categories和ideas应用。Idea模型和Category模型之间应该像使用django-mptt创建分类层级一节中那样具有多对多关联。在数据库中添加一些分类。

如何实现...

将层级分类的QuerySet传递到模板中,然后使用{% recursetree %} 模板标签如下:

  1. 创建加载所有分类的视图并传递至模板中:
# myproject/apps/categories/views.py
from django.views.generic import ListView

from .models import Category

class IdeaCategoryList(ListView):
  model = Category
  template_name = "categories/category_list.html" 
  context_object_name = "categories"
  1. 使用如下内容创建模板来输出分类的层级:
{# categories/category_list.html #}
{% extends "base.html" %}
{% load mptt_tags %}

{% block content %} 
  <ul class="root">
    {% recursetree categories %}
      <li>
        {{ node.title }}
        {% if not node.is_leaf_node %}
          <ul class="children">
            {{ children }}
          </ul>
        {% endif %}
      </li>
    {% endrecursetree %}
  </ul>
{% endblock %}
  1. 创建URL规则来显示视图:
# myproject/urls.py
from django.conf.urls.i18n import i18n_patterns 
from django.urls import path

from myproject.apps.categories import views as categories_views

urlpatterns = i18n_patterns( 
  #...
  path(
    "idea-categories/", 
    categories_views.IdeaCategoryList.as_view(), 
    name="idea_categories",
  ),
)

实现原理...

模板会被渲染为嵌套列表,如下图所示:

TODO

{% recursetree %}版块模板标签接收分类的QuerySet并使用嵌套在该标签中的模板内容渲染列表。这里使用了两个特殊变量:

  • 变量node是Category模型的实例,该模型的字段或方法可用于添加具体的CSS类或用于JavaScript的HTML5 data-*属性,如{{ node.get_descendent_count }}、{{ node.level }}或{{ node.is_root }}。
  • 然后有一个变量children,它定义了当前分类的子节点应在何处进行渲染。

扩展知识...

如果层级结构非常复杂,有20多层,推荐使用 {% full_tree_for_model %} 和 {% drilldown_tree_for_node %} 迭代标签或非递归的tree_info模板过滤器。

ℹ️有关更多如何实现的内容,请参见官方文档

相关内容

  • 第4章 模板和JavaScript使用HTML5 data属性一节
  • 使用django-mptt创建分类层级一节
  • 通过django-treebeard创建层级分类一节
  • 通过django-mptt使用单选项在表单中选取单个分类一节

通过django-mptt使用单选项在表单中选取单个分类

如果希望在表单中显示分类选项会怎样呢?如何展示层级呢?在django-mptt中,有一个特殊的TreeNodeChoiceField表单,可用于在表单选项字段中显示层级结构。下面就来学习如何实现。

准备工作

我们使用前面小节中定义的categories和ideas应用。本节我们需要用到django-crispy-forms。参见第3章 表单和视图通过django-crispy-forms创建表单布局一节来了解如何进行安装。

如何实现...

我们对第3章 表单和视图过滤对象列表一节所创建的过滤表单进行改进,添加一个分类过滤:

  1. 在ideas应用的forms.py文件中,创建一个带有category字段的表单,如下:
# myproject/apps/ideas/forms.py
from django import forms
from django.utils.safestring import mark_safe
from django.utils.translation import ugettext_lazy as _ 
from django.contrib.auth import get_user_model

from crispy_forms import bootstrap, helper, layout
from mptt.forms import TreeNodeChoiceField

from myproject.apps.categories.models import Category 

from .models import Idea, RATING_CHOICES

User = get_user_model()

class IdeaFilterForm(forms.Form): 
  author = forms.ModelChoiceField(
    label=_("Author"), 
    required=False, 
    queryset=User.objects.all(),
  )
  category = TreeNodeChoiceField(
    label=_("Category"),
    required=False,
    queryset=Category.objects.all(), 
    level_indicator=mark_safe("&nbsp;&nbsp;&nbsp;&nbsp;")
  )
  rating = forms.ChoiceField(
    label=_("Rating"), required=False, choices=RATING_CHOICES
  )
  def __init__(self, *args, **kwargs):
    super().__init__(*args, **kwargs)

    author_field = layout.Field("author")
    category_field = layout.Field("category")
    rating_field = layout.Field("rating")
    submit_button = layout.Submit("filter", _("Filter")) 
    actions = bootstrap.FormActions(submit_button)

    main_fieldset = layout.Fieldset( 
      _("Filter"),
      author_field,
      category_field,
      rating_field,
      actions, 
    )

    self.helper = helper.FormHelper() 
    self.helper.form_method = "GET" 
    self.helper.layout = layout.Layout(main_fieldset)
  1. 我们应该已经创建了IdeaListView,其相关联的URL规则以及显示这一表单的idea_list.html 模板。使用 {% crispy %} 模板标签在模板中渲染此过滤器表单,如下:
{# ideas/idea_list.html #}
{% extends "base.html" %}
{% load i18n utility_tags crispy_forms_tags %}

{% block sidebar %}
  {% crispy form %}
{% endblock %}
{% block main %} 
  {# ... #}
{% endblock %}

实现原理...

下拉菜单的分类选项效果如下所示:

TODO

TreeNodeChoiceField有些像ModelChoiceField,但它以缩进的方式显示层级选项。默认TreeNodeChoiceField在深一级的内容之前添加三个短横线---。在本例中,我们通过对字段传递level_indicator参数将层级分隔符修改为了无间断空格(  HTML实体)。为保证无间断空格不被转义,我们使用了mark_safe()函数。

相关内容

  • 使用django-mptt创建分类层级一节
  • 通过django-mptt使用复选框列表在表单中选取多个分类一节

通过django-mptt使用复选框列表在表单中选取多个分类

在表单中需要选取一个或多个分类,可以使用django-mptt所提供多项选择字段TreeNodeMultipleChoiceField。但多项选择字段(如<select multiple>)从界面显示角度来说不够用户友好,因为用户需要滚动并在点选多个选择时按下control或command键。尤其是在需要从非常大量的子项中进行选项而用户又希望一次选择多个时,或者是对于残障人士,如运动感知障碍的人,那将是非常糟糕的用户体验。更好的方式是提供一个复选框列表来供用户选择分类。本节中,我们将创建一个可在表单中显示缩进的层级树状结构复选框。

准备工作

我们使用前面小节中所定义的categories和ideas应用以及项目中应该早已存在的core应用。

如何实现...

渲染带有复选框的分类缩进列表,我们将创建、使用新的MultipleChoiceTreeField表单字段并为该字段创建一个HTML模板。

具体的模板会传递给表单中的crispy_forms布局。按照如下步骤来实现:

  1. 在core应用中,添加form_fields.py文件并创建继承ModelMultipleChoiceField的MultipleChoiceTreeField表单字段,如下:
# myproject/apps/core/form_fields.py
from django import forms

class MultipleChoiceTreeField(forms.ModelMultipleChoiceField): 
  widget = forms.CheckboxSelectMultiple

  def label_from_instance(self, obj):
    return obj
  1. 使用具有分类的新字段来在创建idea的新表单中进行选择。同时,在表单布局中,将自定义模板传递给categories字段,如下所示:
# myproject/apps/ideas/forms.py
from django import forms
from django.utils.translation import ugettext_lazy as _ 
from django.contrib.auth import get_user_model

from crispy_forms import bootstrap, helper, layout 

from myproject.apps.categories.models import Category
from myproject.apps.core.form_fields import MultipleChoiceTreeField

from .models import Idea, RATING_CHOICES 

User = get_user_model()

class IdeaForm(forms.ModelForm):
  categories = MultipleChoiceTreeField( 
    label=_("Categories"), 
    required=False, 
    queryset=Category.objects.all(),
  )

  class Meta: 
    model = Idea
    exclude = ["author"]

  def __init__(self, request, *args, **kwargs):
    self.request = request 
    super().__init__(*args, **kwargs)
    title_field = layout.Field("title") 
    content_field = layout.Field("content", rows="3") 
    main_fieldset = layout.Fieldset(_("Main data"), title_field, content_field)

    picture_field = layout.Field("picture") 
    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",
      template="core/includes/checkboxselectmultiple_tree.html" 
    )
    categories_fieldset = layout.Fieldset( 
      _("Categories"), categories_field,
      css_id="categories_fieldset"
    )

    submit_button = layout.Submit("save", _("Save")) 
    actions = bootstrap.FormActions(submit_button, css_class="my-4")
    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. 基于crispy模板创建一个Bootstrap样式的复选框列表模板,bootstrap4/layout/checkboxselectmultiple.html,如下所示:
{# core/include/checkboxselectmultiple_tree.html #}
{% load crispy_forms_filters l10n %}

<div class="{% if field_class %} {{ field_class }}{% endif %}"{% if flat_attrs %} {{ flat_attrs|safe }}{% endif %}>

  {% for choice_value, choice_instance in field.field.choices %}
  <div class="{%if use_custom_control%}custom-control custom- checkbox{% if inline_class %} custom-control-inline{% endif %}{% else %}form-check{% if inline_class %} form-check- inline{% endif %}{% endif %}">
    <input type="checkbox" class="{%if use_custom_control%} custom-control-input{% else %}form-check-input
      {% endif %}{% if field.errors %} is-invalid{% endif %}" 
      {% if choice_value in field.value or choice_value|stringformat:"s" in field.value or choice_value|stringformat:
      "s" == field.value |default_if_none:""|stringformat:"s" %} checked= "checked"{% endif %} 
      name="{{ field.html_name }}" id="id_{{ field.html_name }}_{{ forloop.counter }}" 
      value="{{ choice_value|unlocalize }}" 
      {{ field.field .widget.attrs|flatatt }}>
    <label class="{%if use_custom_control%}custom-control-label{% else %}form-check-label{% endif %} 
    level-{{ choice_instance.level }}" for="id_{{ field.html_name }}_{{ forloop.counter }}">
       {{ choice_instance|unlocalize }}
    </label>
    {% if field.errors and forloop.last and not inline_class %}
      {% include 'bootstrap4/layout/field_errors_block.html' %}
    {% endif %}
  </div>
  {% endfor %}
  {% if field.errors and inline_class %}
  <div class="w-100 {%if use_custom_control%}custom-control
  custom-checkbox{% if inline_class %} custom-control-inline {% endif %}{% else %}form-check{% if inline_class %} form-check-inline{% endif %}{% endif %}">
    <input type="checkbox" class="custom-control-input {% if
      field.errors %}is-invalid{%endif%}">
    {% include 'bootstrap4/layout/field_errors_block.html' %}
  </div>
  {% endif %}

  {% include 'bootstrap4/layout/help_text.html' %} 
</div>
  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)
    
  2. 添加相关联的模板来显示带有{% crispy %}模板标签的表单,其使用可参见第3章 表单和视图通过django-crispy-forms创建表单布局一节进行了解:

    {# 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 %}
    
  3. 我们还需要一个指向新视图的URL规则,如下:

    # myproject/apps/ideas/urls.py
    from django.urls import path
    
    from .views import add_or_change_idea
    
    urlpatterns = [ 
      #...
      path("add/", add_or_change_idea, name="add_idea"), 
      path("<uuid:pk>/change/", add_or_change_idea,
        name="change_idea"), 
    ]
    
  4. 在CSS文件中添加规则设置margin-left参数,使用复选框树字段模板所生成的class对标签缩进显示,如.level-0、.level-1和 .level-2。确保设置的CSS类的值充分考虑到所在上下文中最深节点的缩进,如下:

    /* myproject/site_static/site/css/style.css */
    .level-0 {margin-left: 0;} 
    .level-1 {margin-left: 20px;} 
    .level-2 {margin-left: 40px;}
    

实现原理...

产生的表单如下:

TODO

不同于Django默认将字段生成硬编码到Python代码中,django-crispy-forms应用使用模板来渲染字段。可以在crispy_forms/templates/bootstrap4下进行查看,并在需要时拷贝至项目模板目录对应的路径下进行修改。

在idea的创建和编辑表单中,我们对categories字段传递了一个自定义表单,它会在包裹复选框的标签中添加.level-* CSS类。正常的CheckboxSelectMultiple微件的问题是,在渲染时它只使用选项值和选项文本,而我们需要用到分类的其它属性,如深度。解决这一问题,我们还创建了一个MultipleChoiceTreeField表单字段,它继承ModelMultipleChoiceField并重写了label_from_instance() 方法来返回分类实例本身,而非其Unicode值。字段的模板看起来很复杂,但是大多中只是使用必要的Bootstrap标记对多复选框模板(crispy_forms/templates/bootstrap4/layout/checkboxselectmultiple.html)的修改。我们主要是进行了添加.level-* CSS 类的微调。

相关内容

  • 第3章 表单和视图通过django-crispy-forms创建表单布局一节
  • 使用django-mptt创建分类层级一节
  • 通过django-mptt使用单选项在表单中选取单个分类一节

通过django-treebeard创建层级分类

树状算法有多种算法,各有各的优点。django CMS所使用的名为django-treebeard的应用,是django-mptt的一个替代,它提供3种树状表单:

  • 相邻列表(Adjacency List)树是简单结构,其中每个节点有一个parent属性。虽然读运算很快,但写的速度很慢。
  • 嵌套集合(Nested Sets)树和MPTT树相同,它们将节点作为集合放到父级之一进行嵌套。这一结构还提供非常快速的读访问,代价是写和删除操作开销更大,尤其是在以指定排序写入的时候。
  • 物化路径(Materialized Path)树在树中的每个节点都有关联的路径属性,即一个表明从根到节点的完整路径,很像是访问网站中指定页面的URL路径。这是所支持的最有效的方式。

为演示它支持所有这3种算法,我们将使用django-treebeard及其对应的API。我们对第3章 表单和视图中的categories应用进行扩展。在修改中,我们通过所支持的树算法来改善Category模型对层级的支持。

准备工作

执行如下步骤:

  1. 使用如下命令在虚拟环境中安装django-treebeard:

    (env)$ pip install django-treebeard==4.3
    
  2. 如尚未创建请创建categories和ideas应用。在配置文件的INSTALLED_APPS中添加categories应用及treebeard,如下:

    # myproject/settings/_base.py
    INSTALLED_APPS = [ 
      #...
      "treebeard",
      #... 
      "myproject.apps.categories", 
      "myproject.apps.ideas",
    ]
    

如何实现...

我们使用物化路径算法来改进Category模型,如下:

  1. 打开models.py文件、更新Category模型来继承 treebeard.mp_tree.MP_Node模型,而非标准的Django模型。它还应当继承第2章 模型和数据库结构中所定义的CreationModificationDateMixin。除mixin中的字段外,Category模型中还需要有一个title字段:

    # myproject/apps/categories/models.py
    from django.db import models
    from django.utils.translation import ugettext_lazy as _ 
    from treebeard.mp_tree import MP_Node
    
    from myproject.apps.core.models import CreationModificationDateBase
    
    class Category(MP_Node, CreationModificationDateBase): 
      title = models.CharField(_("Title"), max_length=200)
      
      class Meta:
        verbose_name = _("Category") 
        verbose_name_plural = _("Categories")
      
      def __str__(self): 
        return self.title
    
  2. 这要求对数据库进行更新,因此接下来我们将对categories应用进行迁移:

    (env)$ python manage.py makemigrations 
    (env)$ python manage.py migrate
    
  3. 通过使用抽象模型继承,treebeard树节点可以使用标准关联与其它模型建立关联。因此Idea模型可以继续与Category之间存在简单的ManyToManyField关联:

    # 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): 
      #...
      categories = models.ManyToManyField( 
        "categories.Category", 
        verbose_name=_("Categories"), 
        related_name="category_ideas",
      )
    

实现原理...

MP_Node抽象模型提供有path、depth和numchild字段,以及steplen、alphabet和node_order_by属性,供Category模型构建树所需:

  • depth和numchild字段提供有关节点位置和后台的元数据。
  • path字段进行了索引,让对其使用LIKE的数据库查询变得非常快速。
  • path字段由固定长度编码的分段组成,每个分段的长度由steplen属性值(默认值为4)所决定,并且编码使用在给定alphabet属性值中的字符(默认为拉丁字母数字字符)。

path、depth和numchild应被看作只读。同时在将第一对象保存到树中之后不应再修改steplen、alphabet和node_order_by的值,否则数据会崩溃。

除新字段和属性外,MP_Node抽象类通过树状结构添加了用于导航的方法。这些方法的一些重要示例列举如下:

  • 如果希望获取到一个分类的祖先节点,它以当前节点从根节点到父节点的祖先节点的QuerySet返回,可使用如下代码:

    ancestor_categories = category.get_ancestors()
    
  • 只获取根分类,通过将深度设为1来标识,可使用如下代码:

    root = category.get_root()
    
  • 如果希望获取一个分类的直接子节点,可使用如下代码:

    children = category.get_children()
    
  • 要获取一个分类的所有后代,以所有子节点及其下面的子节点的QuerySet返回,但又不包含当前节点本身,使用如下代码:

    descendants = category.get_descendants()
    
  • 如果只希望获取后代节点的数量,可使用如下代码:

    descendants_count = category.get_descendant_count()
    
  • 要获取所有兄弟节点,包含引用节点,调用如下方法:

    siblings = category.get_siblings()
    

    ℹ️根分类视为所有其它根分类的兄弟节点。

  • 只获取前、后兄弟节点,使用下面的方法,其中get_prev_sibling()对最左侧兄弟节点返回None,get_next_sibling()对最右侧兄弟节点返回None:

    previous_sibling = category.get_prev_sibling() 
    next_sibling = category.get_next_sibling()
    
  • 同时存在检测分类是否为根节点、叶子节点或关联其它节点的方法:

    category.is_root()
    category.is_leaf() 
    category.is_child_of(another_category) 
    category.is_descendant_of(another_category) 
    category.is_sibling_of(another_category)
    

扩展知识...

本节只涉及到强大的django-treebeard及其物化路径树很浅的部分。用于导航和构建树还存在很多其它方法。此外,物化路径树的API与内嵌集合树和相邻列表树大多相同,只需要将MP_Node换成NS_Node或AL_Node抽象类来分别进行使用即可使用。

ℹ️阅读django-treebeard API文档查看每个树实现的完整可用属性和方法。

相关内容

  • 第3章 表单和视图
  • 使用django-mptt创建分类层级一节
  • 通过django-treebeard创建基本分类后台界面一节

通过django-treebeard创建基本分类后台界面

django-treebeard应用自带有TreeAdmin,继承自标准的ModelAdmin。这让我们可以在后台界面中查看树状节点层级,并按照所使用的树算法展示界面功能。下面就在本节中进行学习。

准备工作

首先按照本章前面通过django-treebeard创建层级分类一节所讲解的配置categories应用和django-treebeard。同时要确保在项目中可使用django-treebeard的静态文件:

(env)$ python manage.py collectstatic

如何实现...

通过继承treebeard.admin.TreeAdmin(替换默认的admin.ModelAdmin)并使用自定义表单工厂创建categories应用Category模型的后台界面,如下:

# myproject/apps/categories/admin.py
from django.contrib import admin
from treebeard.admin import TreeAdmin
from treebeard.forms import movenodeform_factory

from .models import Category

@admin.register(Category) 
class CategoryAdmin(TreeAdmin):
  form = movenodeform_factory(Category)
  list_display = ["title", "created", "modified"] 
  list_filter = ["created"]

实现原理...

根据所使用的实现树后台界面中分类存在两种模式。对物化路径和嵌套集合树提供了一个高级UI,如下所示:

TODO

这一高级视图让我们可以按更好的总览展开或收起分类。可以通过拖拽标题来重新排序或改变依赖关系。在重新排序时,用户界面类似下图:

TODO

如果通过具体字段对分类应用过滤或排序,高级功能会无法使用,但美观的页面和高级页面仍会保留。可以看下面的中间页面,仅显示过去7天创建的分类:

TODO

但如若使用了相邻列表算法,会出现基本版UI,呈现的美观度略差,并且高级UI中所具有的切换和排序功能都没有了。

ℹ️有关django-treebeard后台的更多详情,包括基于界面的截图,请参见官方文档

相关内容

  • 使用django-mptt创建分类层级一节
  • 通过django-treebeard创建层级分类一节
  • 使用django-mptt-admin创建分类后台界面一节