Рубрики
Без рубрики

Использование возможностей Django и Python для создания настраиваемой таксономии

Руководство о том, почему и как вы могли бы построить модель таксономии в Django + Wagtail.

Автор оригинала: LB (Ben Johnston).

Цель этой статьи-представить способ реализации полностью гибкой системы таксономии в вашем приложении Django. Реализация редактирования будет в значительной степени зависеть от использования Wagtail (CMS, построенной на Django), но все равно будет актуальна, если будет использоваться только Django.

Бизнес-кейс

Случай для таксономии может быть широким — возможно, вы разрабатываете блог, вам скучно и действительно нужен сложный способ помечать сообщения. Кроме того, вы можете работать с системой управления знаниями и вам необходимо предоставить структурированную систему для управления иерархической категоризацией информации вашей команды.

В любом случае, важно понять свои цели, прежде чем писать одну строку кода. Или, по крайней мере, напишите какой-нибудь код, расстроитесь, а затем вернитесь, чтобы подумать о том, что вы пытаетесь сделать.

Наши цели

  • Создайте гибкую систему для управления вложенной (древовидной) таксономией.
  • Мы должны уметь заходить сколь угодно глубоко.
  • Мы должны иметь возможность добавлять канонические (правильные) термины, но также иметь место для предоставления и поиска по неправильным терминам (таким как аббревиатуры).
  • Нам нужно минимизировать зависимости и оставаться как можно ближе к соглашениям Django (для будущей ремонтопригодности).
  • Избегайте любых трудных для понимания терминов в пользовательском интерфейсе (например, таксономии).

Что такое таксономия бизнеса?

Рад, что вы спросили! Подумайте о таксономии как о глобальном общем словаре для бизнеса или организации. Этот словарь часто используется во всей документации, классификации и обучении, но никогда не записывается в одном месте.

Таксономия помогает организовать содержание и знания в иерархические отношения, добавляя детали к терминам и понятиям по мере продвижения по уровням.

Эти две ссылки добавляют немного больше контекста:

Носить правильную шляпу

Когда я работал над аналогичным проектом для клиента, мне было трудно переключаться между правильными шляпами .

Один из них был бизнес-аналитиком, он же парень, которому нужно перевести то, что попросил босс. Надев эту шляпу, я обнаружил, что существуют законные опасения по поводу того, как информация компании может управляться, быть доступной для поиска и классифицироваться, чтобы помочь повысить ценность организации в целом.

Следующее, что было у разработчика. Здесь мне пришлось работать с существующим кодом и фреймворками, чтобы быстро и просто реализовать сложное решение, а также рассмотреть возможность будущей разработки, где это возможно.

Наконец, шляпа, которая имеет значение в долгосрочной перспективе, — это шляпа повседневного пользователя. Именно эту шляпу мне часто было труднее всего сделать после того, как я долго носил другие.

Концепции, как абстрактные, так и модели данных, имели для меня смысл, и мне казалось, что все остальные легко войдут в игру. На самом деле, я должен был помнить, что я думал и проводил мозговой штурм этого проекта в течение длительного времени и имел возможность действительно усвоить цели и способ мышления.

В конце концов мы остановились на одном замечательном предложении, которое помогло нашим конечным пользователям понять концепцию нашей “таксономии”. Мы также отказались от таксономии имен и вместо этого использовали более дружественную внутреннюю терминологию.

Предпосылки

Установка Трясогузки 2.0 . На момент публикации это все еще кандидат на выпуск, но достаточно надежный для использования.

Мы будем использовать Django 2.0 и весь синтаксис Python 3.5+ (потому что это потрясающе!).

Наконец, мы воспользуемся невероятным проектом Python под названием django-treebeard . Я впервые подробно узнал об этом проекте после того, как некоторое время работал с Трясогузкой.

По сути, эта библиотека берет на себя всю тяжелую работу по управлению вложенной моделью дерева внутри стандартной реляционной базы данных. Именно такие проекты, как этот, приводят меня в восторг от мощи Python, а также от того, как можно расширить Django. Крикните @tabo для этого эпического проекта.

Примечание: Если у вас есть трясогузка и работает, вам не нужно будет устанавливать django-treebeard. Для необработанного проекта Django вам нужно будет установить пакет.

Пошаговое руководство по коду

1 – модель “Узла”

Назвать это трудно. На данный момент мы просто назовем наши элементы внутри таксономии “узлом”. Наши узлы расширят Материализованные узлы дерева путей проекта django-treebeard , описанные следующим образом:

  • Каждый узел имеет один единственный путь в дереве (подумайте о путях URL).
  • Должен быть один корневой узел, к которому подключаются все остальные узлы.
  • Узлы могут быть упорядочены по отношению к их братьям и сестрам. Сначала мы просто закажем их по имени, полю.
  • Узлы имеют поле path , depth и numchild , значения которых не должны изменяться напрямую.
  • Настройка по умолчанию может иметь глубину 63, что, я уверен, будет достаточно для нашего варианта использования.

Мы будем добавлять наши собственные поля в Узел модель:

  • имя – поле символов, представляющее каноническое имя узла.
  • псевдонимы – текстовое поле, в котором каждая строка представляет другое потенциальное имя или аббревиатуру узла.
  • node_order_index – целочисленное поле, которое можно использовать в будущем, если мы хотим реализовать пользовательский порядок в пользовательском интерфейсе.

Вот наше начальное определение модели для Узла модели:

# File: my_app/models.py
from django import forms
from django.core.validators import MinLengthValidator
from django.db import models

from treebeard.mp_tree import MP_Node

from wagtail.admin.edit_handlers import FieldPanel


class Node(MP_Node):
    """Represents a single nestable Node in the corporate taxonomy."""

    # node editable fields
    name = models.CharField(
        max_length=50,
        unique=True,
        help_text='Keep the name short, ideally one word.',
        validators=[MinLengthValidator(5)]
    )
    aliases = models.TextField(
        'Also known as',
        max_length=255,
        blank=True,
        help_text="What else is this known as or referred to as?"
    )

    # node tree specific fields and attributes
    node_order_indaex = models.IntegerField(
        blank=True,
        default=0,
        editable=False
    )
    node_child_verbose_name = 'child'
    # important: node_order_by should NOT be changed after first Node created
    node_order_by = ['node_order_index', 'name']

    # wagtail specific - simple way to declare which fields are editable
    panels = [
        FieldPanel('parent'),  # virtual field - see TopicForm later
        FieldPanel('name'),
        FieldPanel('aliases', widget=forms.Textarea(attrs={'rows': '5'})),
    ]

После объявления этой модели вам потребуется выполнить миграцию в консоли:

  • $ python3 ./manage.py makemigrations
  • $ python3 ./manage.py миграция

2 – Форма

Для простоты мы предположим, что весь код будет идти в одном и том же models.py файл. На практике вам лучше всего было бы разделиться на отдельные файлы, но легче встать и работать со всем в одном месте.

Мы будем использовать систему построения форм Wagtail, но вы можете применить основные __unit__ и __save__ переопределения к любой форме Django или даже к Django modeladmin.

Основные пункты, которые следует отметить:

  • Ссылка на API узла django-treebeard пригодится здесь, мы будем использовать такие методы, как get_depth и is_root из этого API.
  • parent – это поле, которое предоставляет пользовательский интерфейс для выбора родителя редактируемого (или создаваемого) узла. Мы расширили класс ModelChoiceField , чтобы создать пользовательское BasicNodeChoiceField , где мы можем получить хорошее представление о структуре узлов в нашем поле выбора.
  • ____ в нем__ в нашей форме было изменено несколько вещей.

    • экземпляр будет экземпляром узла, связанного со значениями, указанными при отправке формы, при создании или редактировании узла.
    • Если мы редактируем корневой узел ( instance.is_root() ) или создаем первый узел ( Node.objects.count() равен 0 ), мы хотим убедиться, что поле parent скрыто и не выдаст ошибку, если оно не заполнено.
    • Если мы редактируем существующий узел, мы хотим предварительно выбрать родительский узел с помощью get_parent() .
  • сохранить необходимо изменить для работы с django-treebeard API, так как мы не можем просто создавать или перемещать узлы напрямую.

    • Сначала мы получаем узел экземпляр , который пытается быть сохранен, затем мы получаем значение parent , отправленное с формой (которая не будет для корневого узла).
    • Если мы не фиксируем изменения в этом вызове сохранения, мы можем просто вернуть предоставленный экземпляр.
    • В противном случае мы хотим рассмотреть следующие случаи:
      • Создание первого узла, который станет корневым узлом, обрабатывается методом класса add_root .
      • Создание узла, но не корневого узла, который должен быть размещен в качестве дочернего под существующим родительским узлом через add_child на родительском узле.
      • Внесение изменений, не являющихся родительскими, в любой узел обрабатывается обычным методом save .
      • Перемещение существующего узла в новое расположение под другим родительским узлом, обрабатываемое move(parent,) .
    • Наконец, мы говорим трясогузке использовать этот класс формы при редактировании модели узла через Node.base_form_class .
# File: my_app/models.py
# ... other imports from previous sections
from django import forms

from wagtail.admin.forms import WagtailAdminModelForm


class BasicNodeChoiceField(forms.ModelChoiceField):
    def label_from_instance(self, obj):
        depth_line = '-' * (obj.get_depth() - 1)
        return "{} {}".format(depth_line, super().label_from_instance(obj))


class NodeForm(WagtailAdminModelForm):

    parent = BasicNodeChoiceField(
        required=True,
        queryset=Node.objects.all(),
        empty_label=None,
    )

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        instance = kwargs['instance']

        if instance.is_root() or Node.objects.count() is 0:
            # hide and disable the parent field
            self.fields['parent'].disabled = True
            self.fields['parent'].required = False
            self.fields['parent'].empty_label = 'N/A - Root Node'
            self.fields['parent'].widget = forms.HiddenInput()

            # update label to indicate this is the root
            self.fields['name'].label += ' (Root)'
        elif instance.id:
            self.fields['parent'].initial = instance.get_parent()

    def save(self, commit=True, *args, **kwargs):
        instance = super().save(commit=False, *args, **kwargs)
        parent = self.cleaned_data['parent']

        if not commit:
            # simply return the instance if not actually saving (committing)
            return instance

        if instance.id is None:  # creating a new node
            if Node.objects.all().count() is 0:  # no nodes, creating root
                Node.add_root(instance=instance)  # add a NEW root node
            else:  # nodes exist, must be adding node under a parent
                instance = parent.add_child(instance=instance)
        else:  # editing an existing node
            instance.save()  # update existing node
            if instance.get_parent() != parent:
                instance.move(parent, pos='sorted-child')
        return instance


Node.base_form_class = NodeForm

3 – Редактирование модели трясогузки

Теперь мы будем использовать модуль Wagtail modeladmin . Это мощный способ добавить операции CRUD в наши модели в интерфейсе администратора. Он похож (по концепции) на modeladmin Джанго, но не то же самое. Он также широко использует потрясающие представления на основе классов .

Примечание: Представления на основе классов предоставляют отличный способ добавить функциональность в Django, не изобретая велосипед. Они легко настраиваются и предоставляют отличный API, который легко расширяется и дает вам отличный пример структуры для классов представлений.

Мы объявим новый класс, который расширит ModelAdmin :

  • model устанавливается в наш класс Node model.
  • list_display имеет наше имя и псевдоним поле, а также метод, доступный в MP_Node классе get_parent .
  • inspect_view_enabled означает, что пользователи могут нажать на простую страницу просмотра, чтобы просмотреть детали, но ничего не редактировать на узле.
# File: my_app/models.py
# ... other imports from previous sections

from wagtail.contrib.modeladmin.options import ModelAdmin


class NodeAdmin(ModelAdmin):
    """Class for presenting topics in admin using modeladmin."""

    model = Node

    # admin menu options
    menu_icon = 'fa-cube'  # using wagtail-fontawesome
    menu_order = 800

    # listing view options
    list_per_page = 50
    list_display = ('name', 'get_parent', 'aliases')
    search_fields = ('name', 'aliases')

    # inspect view options
    inspect_view_enabled = True
    inspect_view_fields = ('name', 'get_parent', 'aliases', 'id')

Затем мы зарегистрируем наш пользовательский ModelAdmin в новом файле с именем wagtail_hooks.py . Это специальное соглашение об именах файлов, которое Wagtail обеспечит запуск до подготовки интерфейса администратора.

# File: my_app/wagtail_hooks.py

from .models import NodeAdmin
from wagtail.contrib.modeladmin.options import modeladmin_register

modeladmin_register(NodeAdmin)

Усовершенствования модели с 4 узлами

Для второго раунда нашего определения модели мы добавим несколько хороших вспомогательных методов, которые будут использоваться позже.

  • Узел теперь также расширяет индекс.Индексированный — это обеспечивает возможность для этой модели быть индексированной для поиска . См.Также определение search_fields в модели для полей, которые мы добавили в индекс.
  • get_as_listing_header – это метод, который отображает пользовательский шаблон, показывающий “глубину” наших узлов. Мы также установили атрибуты short_description и admin_order_field для этого метода, используемые modeladmin для отображения хорошего заголовка столбца.
  • get_parent – это точно такой же метод, предоставляемый MP_node . Однако нам нужно повторно объявить его в модели, чтобы установить short_description , используемый modeladmin .
  • метод delete переопределяется, чтобы заблокировать удаление корневого узла. Это действительно важно — если он будет удален, дерево узлов будет повреждено, и хаос войдет в древний лес.
  • __str__ магический метод используется для отображения хорошего строкового представления наших узлов.
  • Наконец, мы решили, что Node не является дружественным именем для нашей команды. Вместо этого мы решили использовать Topic . modeladmin также будет соблюдать эту ссылку и автоматически использовать ее в интерфейсе администратора.
# File: my_app/models.py
from django import forms
from django.core.exceptions import PermissionDenied
from django.core.validators import MinLengthValidator
from django.db import models
from django.template.loader import render_to_string # added

from treebeard.mp_tree import MP_Node

from wagtail.admin.edit_handlers import FieldPanel
from wagtail.search import index # added


class Node(index.Indexed, MP_Node):  # Note: Now using index.Indexed in model
    """Represents a single nestable Node in the corporate taxonomy."""

    # ...name, aliases and other attributes defined above go here

    def get_as_listing_header(self):
        """Build HTML representation of node with title & depth indication."""
        depth = self.get_depth()
        rendered = render_to_string(
            'includes/node_list_header.html',
            {
                'depth': depth,
                'depth_minus_1': depth - 1,
                'is_root': self.is_root(),
                'name': self.name,
            }
        )
        return rendered
    get_as_listing_header.short_description = 'Name'
    get_as_listing_header.admin_order_field = 'name'

    def get_parent(self, *args, **kwargs):
        """Duplicate of get_parent from treebeard API."""
        return super().get_parent(*args, **kwargs)
    get_parent.short_description = 'Parent'

    search_fields = [
        index.SearchField('name', partial_match=True),
        index.SearchField('aliases', partial_match=False, boost=0.25),
    ]

    def delete(self):
        """Prevent users from deleting the root node."""
        if self.is_root():
            raise PermissionDenied('Cannot delete root Topic.')
        else:
            super().delete()

    def __str__(self):
        return self.name

    class Meta:
        verbose_name = 'Topic'
        verbose_name_plural = 'Topics'

Вот шаблон, используемый нашим методом get_as_listing_header .

{# File: my_app/templates/includes/node_list_header.html #}
{% if is_root %}
  {{ name }}
{% else %}
  
    
    
    {{ name }}
  
{% endif %}

Затем нам нужно обновить определение нашего NodeAdmin , чтобы воспользоваться нашим довольно get_as_listing_header методом.

class NodeAdmin(ModelAdmin):
    #... other options
    # listing view options ('name' replaced with 'get_as_listing_header')
    list_display = ('get_as_listing_header', 'get_parent', 'aliases')

5 – Завершение работы

Теперь мы можем добавить отношение к нашим узлам на любой из наших других моделей, где это уместно.

Мы можем добавить отношение “много к одному”, используя Внешний ключ .

KnowledgePage(Page):
    # ... other fields
    node = models.ForeignKey(
        'my_app.Node',
        on_delete=models.CASCADE,
    )

Мы можем добавить отношение “многие ко многим”, используя ManyToManyField .

KnowledgePage(Page):
    # ... other fields
    nodes = models.ManyToManyField('my_app.Node')

Теперь у нас есть интерфейс для управления нашей таксономией, а также способ связать узлы с любой другой моделью в Django.

Бонусные баллы – Добавление глазури на корневой узел

Скрыть кнопку удаления на корневом узле

Приятно не показывать кнопки, которые пользователи не должны использовать. К счастью, modeladmin позволяет легко переопределить способ создания кнопок для каждой строки.

# File: my_app/models.py
from wagtail.contrib.modeladmin.helpers import ButtonHelper # add import

class NodeButtonHelper(ButtonHelper):
    def delete_button(self, pk, *args, **kwargs):
        """Ensure that the delete button is not shown for root node."""
        instance = self.model.objects.get(pk=pk)
        if instance.is_root():
            return
        return super().delete_button(pk, *args, **kwargs)

class NodeAdmin(ModelAdmin):
    #... other options
    button_helper_class = NodeButtonHelper

Кнопка Добавить для быстрого добавления дочернего узла

Это немного сложнее, но стоит того, чтобы понять, как глубоко работать с представлениями на основе классов и modeladmin.

Прохождение:

  • Помощник кнопки узла имеет несколько изменений, чтобы по существу создать и вставить новую кнопку add_child_button , которая обеспечит простой способ предварительного заполнения родительского поля в представлении создания узла.
  • Класс представления узла addChild расширяет класс Create View . Здесь мы делаем несколько вещей:

    • ____in it__ получает pk (первичный ключ) из запроса и проверяет его действительность с помощью подготовленного набора запросов и get_object_or_404 .
    • get_page_title дает пользователю более приятный заголовок на странице создания, соответствующий выбранному им родителю.
    • get_initial задает начальные значения для нашей формы Узла . Для этого не требуется никаких изменений в форме узла .
  • Внутри нашего NodeAdmin мы переопределяем два метода:

    • add_child_view — это дает модулю modeladmin ссылку на представление для назначения соответствующего URL-адреса.
    • get_admin_urls_for_registration — это добавляет наш новый URL-адрес для приведенного выше представления в процесс регистрации (администратор Wagtail требует, чтобы все шаблоны URL-адресов администратора были зарегистрированы определенным образом).
# File: my_app/models.py
from django.conf.urls import url
from django.contrib.admin.utils import quote, unquote
from django.shortcuts import get_object_or_404

from wagtail.contrib.modeladmin.helpers import ButtonHelper
from wagtail.contrib.modeladmin.views import CreateView


class NodeButtonHelper(ButtonHelper):
    # delete_button... see above

    def prepare_classnames(self, start=None, add=None, exclude=None):
        """Parse classname sets into final css classess list."""
        classnames = start or []
        classnames.extend(add or [])
        return self.finalise_classname(classnames, exclude or [])

    def add_child_button(self, pk, child_verbose_name, **kwargs):
        """Build a add child button, to easily add a child under node."""
        classnames = self.prepare_classnames(
            start=self.edit_button_classnames + ['icon', 'icon-plus'],
            add=kwargs.get('classnames_add'),
            exclude=kwargs.get('classnames_exclude')
        )
        return {
            'classname': classnames,
            'label': 'Add %s %s' % (
                child_verbose_name, self.verbose_name),
            'title': 'Add %s %s under this one' % (
                child_verbose_name, self.verbose_name),
            'url': self.url_helper.get_action_url('add_child', quote(pk)),
        }

    def get_buttons_for_obj(self, obj, exclude=None, *args, **kwargs):
        """Override the getting of buttons, prepending create child button."""
        buttons = super().get_buttons_for_obj(obj, *args, **kwargs)

        add_child_button = self.add_child_button(
            pk=getattr(obj, self.opts.pk.attname),
            child_verbose_name=getattr(obj, 'node_child_verbose_name'),
            **kwargs
        )
        buttons.append(add_child_button)

        return buttons

class AddChildNodeViewClass(CreateView):
    """View class that can take an additional URL param for parent id."""

    parent_pk = None
    parent_instance = None

    def __init__(self, model_admin, parent_pk):
        self.parent_pk = unquote(parent_pk)
        object_qs = model_admin.model._default_manager.get_queryset()
        object_qs = object_qs.filter(pk=self.parent_pk)
        self.parent_instance = get_object_or_404(object_qs)
        super().__init__(model_admin)

    def get_page_title(self):
        """Generate a title that explains you are adding a child."""
        title = super().get_page_title()
        return title + ' %s %s for %s' % (
            self.model.node_child_verbose_name,
            self.opts.verbose_name,
            self.parent_instance
        )

    def get_initial(self):
        """Set the selected parent field to the parent_pk."""
        return {'parent': self.parent_pk}


class NodeAdmin(ModelAdmin):
    #... other NodeAdmin attributes...

    def add_child_view(self, request, instance_pk):
        """Generate a class-based view to provide 'add child' functionality."""
        # instance_pk will become the default selected parent_pk
        kwargs = {'model_admin': self, 'parent_pk': instance_pk}
        view_class = AddChildNodeViewClass
        return view_class.as_view(**kwargs)(request)

    def get_admin_urls_for_registration(self):
        """Add the new url for add child page to the registered URLs."""
        urls = super().get_admin_urls_for_registration()
        add_child_url = url(
            self.url_helper.get_action_url_pattern('add_child'),
            self.add_child_view,
            name=self.url_helper.get_action_url_name('add_child')
        )
        return urls + (add_child_url, )

В заключение

Я очень надеюсь, что это было полезно как с технической точки зрения, так и с точки зрения “обдумывания”.

В этой реализации есть много возможностей для улучшения, но это надежная отправная точка. Отсюда вы можете создавать свои собственные удивительные системы таксономии в каждом приложении… это необходимо.

Вы можете просмотреть полный текст models.py файл на GitHub gist . Есть несколько незначительных дополнений и настроек, основанных на проекте, на котором я основал этот блог.

Фото в заголовке Уилл Тернер вкл Unsplash .