Skip to content

Instantly share code, notes, and snippets.

@lb-
Last active March 28, 2023 17:20
Show Gist options
  • Save lb-/fda43b343cbf24c44c2c74ec69f2eafd to your computer and use it in GitHub Desktop.
Save lb-/fda43b343cbf24c44c2c74ec69f2eafd to your computer and use it in GitHub Desktop.
Django + Wagtail - custom taxonomy model
"""Node model and Node admin interaction."""
from django import forms
from django.conf.urls import url
from django.contrib.admin.utils import quote, unquote
from django.core.exceptions import PermissionDenied
from django.core.validators import MinLengthValidator, RegexValidator
from django.db import models
from django.shortcuts import get_object_or_404
from django.template.loader import render_to_string
from treebeard.mp_tree import MP_Node
from wagtail.admin.edit_handlers import FieldPanel
from wagtail.admin.forms import WagtailAdminModelForm
from wagtail.contrib.modeladmin.helpers import ButtonHelper
from wagtail.contrib.modeladmin.options import ModelAdmin
from wagtail.contrib.modeladmin.views import CreateView
from wagtail.search import index
node_name_validator = RegexValidator(
regex='^[\w][a-zA-Z &]+$',
message="Letters, numbers and '&' only plus must start with a letter.",
)
class Node(index.Indexed, 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=[node_name_validator, 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_index = models.IntegerField(
blank=True,
default=0,
editable=False
)
node_child_verbose_name = 'child'
node_order_by = ['node_order_index', 'name']
panels = [
# FieldPanel('parent'), # virtual field - see TopicForm
FieldPanel('name'),
FieldPanel('aliases', widget=forms.Textarea(attrs={'rows': '5'})),
]
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'
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
class NodeButtonHelper(ButtonHelper):
"""Custom button functionality for node listing buttons."""
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 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)
def inspect_button(self, *args, **kwargs):
"""Replace the term 'Inspect' with 'Details' in listing buttons."""
button = super().inspect_button(*args, **kwargs)
button['label'] = button['label'].replace('Inspect', 'Details')
button['title'] = button['label'].replace('Inspect', 'Details', 1)
return button
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):
"""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_display = ('get_as_listing_header', 'get_parent', 'aliases')
list_per_page = 50
search_fields = ('name', 'aliases')
# inspect view options
inspect_view_enabled = True
inspect_view_fields = ('name', 'get_parent', 'aliases', 'id')
# other overrides
button_helper_class = NodeButtonHelper
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, )
{% if is_root %}
<span style="font-size:135%;"><strong>{{ name }}</strong></span>
{% else %}
<span>
<span class="inline-block" style="margin-left:{{ depth_minus_1 }}em; font-size:{% if depth is 1 %}120{% elif depth is 2 %}110{% else %}100{% endif %}%;"></span>
<i class="icon icon-fa-level-up icon-fa-rotate-90" style="display: inline-block;"></i>
{{ name }}
</span>
{% endif %}
from .models import NodeAdmin
from wagtail.contrib.modeladmin.options import modeladmin_register
modeladmin_register(NodeAdmin)
@lb-
Copy link
Author

lb- commented Jul 25, 2021

Hey, this code was written a while ago and likely will not work in more recent versions of Wagtail.

@bbliem
Copy link

bbliem commented Aug 9, 2021

However, when testing this in Wagtail 2.13 there seems to be an error when trying to save the form. Django is complaining that the "depth" value is null, do you know if there is a work-around?

I had a similar issue and in my case it seemed to be because I forgot to set Node.base_form_class (Line 151), so the custom form wasn't used and thus saving the instance wouldn't call add_child().

PS: I'm using Wagtail 2.12 at the moment, so it may well be that something changed with 2.13.

@jessemaga
Copy link

This looks like a very cool write-up. However, when testing this in Wagtail 2.13 there seems to be an error when trying to save the form. Django is complaining that the "depth" value is null, do you know if there is a work-around?

TYIA

Did you ever solve this problem?

@kunambi
Copy link

kunambi commented Oct 30, 2022

Did you ever solve this problem?

Sorry for late reply, but yes I managed to solve it by using the suggestion provided by @bbliem

@kamartem
Copy link

Very useful article and clean implementation, found it in many places and wondering about one thing. Suppose i want to Node be able to have some personal url and content for it representation (for example instance of or reference to Wagtail Page)

What can you say about most correct approach to reach the goal?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment