In my blog, I list a number of related blog posts for each post. This is based on the tags I apply to the posts.

I'm using the taggit module to manage the tags. I'm also using wagtailmarkdown for having Markdown support in the post body.

My model looks like this:

blog/models.py

from django.db import models
from django.db.models.aggregates import Count
from django.db.models.fields import DateTimeField
from django.utils import timezone

from modelcluster.fields import ParentalKey
from modelcluster.contrib.taggit import ClusterTaggableManager
from taggit.models import TaggedItemBase

from wagtail.admin.edit_handlers import FieldPanel, MultiFieldPanel
from wagtail.core.models import Page, PageManager

from wagtailmarkdown.edit_handlers import MarkdownPanel
from wagtailmarkdown.fields import MarkdownField

class BlogPostTag(TaggedItemBase):
    content_object = ParentalKey('BlogPost', related_name='tagged_items', on_delete=models.CASCADE)

class BlogPostManager(PageManager):

    def related_posts(self, post, max_items=5):
        tags = post.tags.all()

        matches = BlogPost.objects.filter(tags__in=tags).live().annotate(Count('title'))
        matches = matches.exclude(pk=post.pk)

        related = matches.order_by('-title__count')
        return related[:max_items]

class BlogPost(Page):

    objects = BlogPostManager()

    body = MarkdownField(blank=True)
    tags = ClusterTaggableManager(through=BlogPostTag, blank=True)

    date = DateTimeField(blank=True, default=None, null=True, db_index=True)
    date.verbose_name = 'Publish Date'

    content_panels = [
        MultiFieldPanel(
            [
                FieldPanel('title', classname="full title"),
                FieldPanel('date'),
                FieldPanel('tags'),
            ],
            heading="Post Details",
            classname="collapsible"
        ),
        MarkdownPanel('body'),
    ]
    
    def get_context(self, request, *args, **kwargs):
        context = super(BlogPost, self).get_context(request)
        context['related_posts'] = BlogPost.objects.related_posts(self)
        return context

A whole lot of code, but lets look at it step by step.

At the top, you'll find the list of imports of all the items we need.

We first define BlogPostTag which is a single tag which can be added to a blog post. The blog posts themselves are defined in the class BlogPost which inherits from the base Page class. I do this to get base functionality of a Wagtail page in there. So far, that's all pretty standard.

We also define a couple of field such as body and date. To add tags to the blog post, we define a ClusterTaggableManager through the BlogPostTag class. This essentially creates a many-to-many relation between the blog posts and the tags. To make them editable, we also need to add them to the content_panels.

I also created a class BlogPostManager which inherits from Wagtail's PageManager class. I like this approach as it keeps the functions to access the blog posts nicely separated from the actual blog post definition. By using my custom manager as the objects variable in the BlogPost class, it makes it nice and clean.

In this example, BlogPostManager only defines one single extra method called related_posts. This is the method responsible for finding the related posts. It will first get the list of tags assigned to the given post. Based on that list, it will filter out all live blog posts which have tags in common. It also annotates the them by the number of tags they have in common. We also exclude the blog posts itself as we don't want it to show in the listing.

The list is ordered starting with the posts that have most tags in common.

I also added a limit so that we don't get all posts, but just a subset.

This is then used in the get_context function of the blog post so that they are available in the template.

In the template, we can show the related posts with a loop statement:

blog/templates/blog/blog_post.html

<h1>{{ page.title }}</h1>

{{ page.body | markdown | safe }} 

{% if related_posts %}
    <h1 class="related">Related Posts</h1
    <ul>
        {% for post in related_posts %}
            <li><a href="{% pageurl post %}">{{ post.title }}</a></li>
        {% endfor %}
    </ul>
{% endif %}