Building a configurable taxonomy in Wagtail (Django)
--
Updated 2018–02–17: Added brewing methods chart + screenshots of final UI.
This article aims to present a way to implement a fully flexible taxonomy system inside your Django app. The editing implementation will rely heavily on the use of Wagtail (a CMS built on Django) but will still be relevant if only Django is used. This is a repost from my original article on Codementor.
Business case
The case for a taxonomy can be broad — you may be developing a blog, are bored, and really want a complicated way to tag posts. Alternatively, you may be working with a knowledge management system and need to provide a structured system to manage hierarchal categorization of your team’s information.
Either way, it is important to understand your goals before writing a single line of code. Or at least write some code, get frustrated, and then come back to think about what you are trying to do.
Goals
- Build a flexible system to manage a nested (tree shaped) taxonomy.
- We must be able to go arbitrarily deep.
- We must be able to add the canonical (correct) terms but also have space to provide and search via the non-correct terms (such as abbreviations).
- We need to minimize dependencies and stay as close to Django conventions as possible (for future maintainability).
- Avoid any difficult to understand terms in the user interface (e.g. taxonomy).
What is a business taxonomy?
Glad you asked! Think of a taxonomy as a globally shared vocabulary for the business or organization. This vocabulary is often used throughout all documentation, categorization, and training, but never really written down in one place.
Taxonomies help organize content and knowledge into hierarchical relationships, adding detail to terms and concepts the further you go down the levels.
These two links add a bit more context:
Wearing the right hat
When I worked on a similar project for a client, one thing I found hard was switching between the right hats.
One hat was the business analyst, a.k.a, the guy who needs to translate what the boss has asked for. With this hat on, I found that there were legitimate concerns over how the company’s information could be managed, searchable, and categorized to help add value to the organization as a whole.
The next hat was that of the developer. Here, I had to work with existing code and frameworks to implement a complex solution quickly and simply, along with consideration for future development wherever possible.
Finally, the hat that matters in the long run — the one of the everyday user. It was this hat I often found the hardest to don after wearing the others for a long time.
The concepts, both abstract and data model side, made sense to me and it felt like everyone else would get on board easily. In reality, I had to remember that I had been thinking and brainstorming this project for a long time and had the chance to really internalize the goals and way to think.
In the end, we landed on a great single sentence that helped our end users grok the concept of our ‘taxonomy’. We also ditched the name taxonomy all together and used a more friendly internally relevant terminology instead.
Prerequisites
Installation of Wagtail 2.0. As of publication, this is still a release candidate but is solid enough to use.
We will be using Django 2.0 and all Python 3.5+ syntax (because it is awesome!).
Finally, we will be taking advantage of an incredible Python project called django-treebeard. I first found out about this project in depth after working with Wagtail for a while.
Essentially, this library takes all of the heavy lifting of managing a nested tree model inside a standard relational database. It is projects like this that get me excited about the power of Python and also the way Django can be extended. Shout out to @tabo for this epic project.
Note: If you have Wagtail up and running, you will not need to install django-treebeard. For a raw Django project, you will need to install the package.
Code walkthrough
1 — the ‘Node’ model
Naming this is hard. For now, we will just call our elements inside the taxonomy a ‘node’. Our nodes will extend the django-treebeard project’s Materialized Path Tree nodes, described as follows:
- Each node has one single path in the tree (think URL paths).
- There must be one single root node that all other nodes connect to.
- Nodes can be ordered in relation to their siblings. Initially, we will just order them by their name, the field.
- Nodes have a
path
,depth
, andnumchild
field whose values should not be changed directly. - The default set up can have a depth of 63, which I am sure will be sufficient for our use case.
We will be adding our own fields to the Node
model:
name
- a CharField that represents the canonical name of the Node.aliases
- a TextField where each line represents another potential name or abbreviation for the Node.node_order_index
- an IntegerField which can be used in the future if we want to implement custom ordering in the user interface.
Here is our initial model definition for the Node
model:
After you have this model declared, you will want to run migrations in your console:
$ python3 ./manage.py makemigrations
$ python3 ./manage.py migrate
2 — The form
For the sake of simplicity, we will assume all of the code will go in the same models.py
file. In practice, you would be best served splitting up into separate files, but it is easier to get up and running with everything in one place.
We will be using the Wagtail system of building forms, but you can apply the main __init__
and __save__
overrides to any Django form or even Django modeladmin.
Key items to note:
- The djang-treebeard node API reference will come in handy here, we will be using methods like
get_depth
andis_root
from this API. parent
is a field that provides a user interface to select the parent of the node being edited (or created). We have extended theModelChoiceField
class to create a customBasicNodeChoiceField
where we can get a nice indication of the Node structure in our select box.__init__
on our form has been modified to do a few things.instance
will be an instance of Node bound to the values provided when the form submits, when creating or editing a Node.- If we are editing the root node (
instance.is_root()
) or creating the first node (Node.objects.count() is 0
) we want to ensure that theparent
field is hidden and will not throw an error if not filled out. - If we are editing an existing node we want to pre-select the node’s parent via
get_parent()
. save
needs to be changed to work with thedjango-treebeard
API, as we cannot just create or move Nodes directly.- First, we get the Node
instance
that is attempting to be saved, then we get the value ofparent
submitted with the form (which will be None for the root Node). - If we are not committing changes on this save call, we can simply return the instance provided.
- Otherwise, we want to handle the following cases:
- Creating the first Node, which will become the root Node, handled by the classmethod
add_root
. - Creating a Node, but not the root Node, which must be placed as a child under an existing parent Node via
add_child
on the parent node. - Making non-parent changes to any Node is handled by the normal
save
method. - Moving an existing node to a new location under a different parent Node, handled by
move(parent, pos='sorted-child')
. - Finally, we tell Wagtail to use this form class when editing the Node model via
Node.base_form_class = NodeForm
.
3 — Wagtail modeladmin editing
We will now use the Wagtail modeladmin module. This is a powerful way to add CRUD operations to our models in the admin interface. It is similar (in concept) to Django’s modeladmin, but not the same. It also makes extensive use of the awesome Class-based views.
Note: The Class-based views provide a great way to add functionality to Django without reinventing the wheel. They are easy to customize and provide a great API that is easy to extend and gives you a great example of a structure for view classes.
We will be declaring a new class that will extend ModelAdmin
:
model
is set to ourNode
model class.list_display
has ourname
andalias
field, along with a method available on theMP_Node
classget_parent
.inspect_view_enabled
means that the users can click on a simple view page to look at details but not edit anything on the Node.
We will then register our custom ModelAdmin
in a new file called wagtail_hooks.py
. This is a special file name convention that Wagtail will ensure runs before the admin interface is prepared.
4 — Node model enhancements
For round two of our model definition, we will add some nice helper methods to be used later.
Node
now also extendsindex.Indexed
— this provides the ability for this model to be indexed for searching. See also thesearch_fields
definition on the model for the fields we have added to the index.get_as_listing_header
is a method that renders a custom template that shows off the 'depth' of our Nodes. We also set theshort_description
andadmin_order_field
attributes on this method, used bymodeladmin
to show a nice column header.get_parent
is just the same method provided byMP_node
. However, we need to re-declare it on the model to set theshort_description
used bymodeladmin
.delete
method is overridden to block the deletion of the root Node. This is really important — if it is deleted, the node tree will be corrupted and chaos will enter the ancient forest.__str__
magic method is used to show a nice string representation of our Nodes.- Finally, we have decided that
Node
is not a friendly name for our team. We have elected to useTopic
instead.modeladmin
will also honor this reference and automatically use it in the admin interface.
Here is the template used by our get_as_listing_header
method.
Then we need to update the definition of our NodeAdmin
to take advantage of our pretty get_as_listing_header
method.
5 — Finishing up
We can now add a relation to our Nodes on any of our other models, where appropriate.
We can add a many-to-one relationship using ForeignKey.
We can add a many-to-many relationship using ManyToManyField.
We now have an interface to manage our taxonomy, along with a way to link the nodes to any other model within Django.
Bonus points — Adding icing on the root Node
Hide delete button on root Node
It is nice to not show buttons that users are not meant to use. Thankfully, modeladmin
makes it easy to override how the buttons for each row are generated.
Add button to quickly add a child node
This is a bit more involved, but worth it to understand how to work with class-based views and modeladmin in depth.
Walkthrough:
NodeButtonHelper
has a few changes to essentially create and insert a new button,add_child_button
, which will provide a simple way to pre-fill the parent field on a create Node view.AddChildNodeViewClass
extends theCreateView
class. Here, we do a few things:__init__
gets the pk (primary key) from the request and checks it is valid via the prepared queryset andget_object_or_404
.get_page_title
gives the user a nicer title on the create page, relevant to the parent they selected.get_initial
sets the initial values for ourNodeForm
. No changes are needed toNodeForm
for this to work.- Inside our
NodeAdmin
, we override two methods: add_child_view
— this gives the modeladmin module a reference to a view to assign to the relevant URL.get_admin_urls_for_registration
— this adds our new URL for the above view to the registration process (Wagtail admin requires all admin URL patterns to be registered a specific way).
In closing
I really hope this has been helpful from both the technical and ‘thinking it through’ perspective.
There is a lot of room for improvement in this implementation, but this is a solid starting point. From here, you can build your own amazing taxonomy systems in every application… that needs it.
You can view the full models.py
file on a GitHub gist. There are a few minor additions and tweaks based on the project I based this blog on.
In my working example I fleshed out the Coffee brewing methods, if we think of each coffee ‘drink’ as the entity that contains links to how it was brewed, we end up with a fun taxonomy example. Below are a few screenshots of how this would look in Wagtail.