Автор оригинала: 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 .