Implementing Related Articles in a Django Blog App with a Custom Tag Model

Table of Contents


Introduction

Adding related articles to a blog app improves user engagement by suggesting content similar to what readers are currently viewing. In a Django blog app, this can be achieved by associating posts with tags and querying posts that share those tags. While django-taggit is a popular choice for tagging, creating a custom tag model offers greater control and eliminates external dependencies. This post walks you through implementing related articles using a custom tag model, tailored for a Django blog app.


Why Use a Custom Tag Model?

A custom tag model provides several advantages over using django-taggit:

  • Full Control: Customize fields, relationships, and behavior (e.g., add descriptions or hierarchies).
  • No Dependencies: Avoid relying on third-party packages, reducing maintenance overhead.
  • Transparency: You fully understand the database schema and queries.
  • Flexibility: Easily extend the system for specific needs, like tag validation or custom ranking.

However, it requires more initial setup and manual implementation of features that django-taggit provides out of the box, such as tag clouds or generic tagging.


Step-by-Step Implementation

Define the Tag Model

Create a Tag model to store tags with fields for name and slug:

from django.db import models
from django.utils.text import slugify

class Tag(models.Model):
    name = models.CharField(max_length=50, unique=True)
    slug = models.SlugField(max_length=50, unique=True, blank=True)

    def save(self, *args, **kwargs):
        if not self.slug:
            self.slug = slugify(self.name)
        super().save(*args, **kwargs)

    def __str__(self):
        return self.name

The save method auto-generates a slug for URL-friendly tags.

Update the Post Model

Add a many-to-many relationship to link posts with tags:

class Post(models.Model):
    title = models.CharField(max_length=200)
    slug = models.SlugField(unique=True)
    content = models.TextField()
    published_date = models.DateTimeField(auto_now_add=True)
    tags = models.ManyToManyField(Tag, related_name='posts', blank=True)

    def __str__(self):
        return self.title

The blank=True option allows posts to have no tags, and related_name='posts' enables reverse queries.

Run Migrations

Generate and apply database migrations:

python manage.py makemigrations
python manage.py migrate

Set Up Admin Integration

Register the models in admin.py for easy management:

from django.contrib import admin
from .models import Post, Tag

@admin.register(Tag)
class TagAdmin(admin.ModelAdmin):
    list_display = ['name', 'slug']
    prepopulated_fields = {'slug': ('name',)}

@admin.register(Post)
class PostAdmin(admin.ModelAdmin):
    list_display = ['title', 'published_date']
    filter_horizontal = ['tags']

The filter_horizontal widget simplifies tag selection, and prepopulated_fields auto-fills slugs.

Add a method to the Post model to query related posts based on shared tags:

class Post(models.Model):
    # ... other fields ...

    def get_related_posts(self, limit=3):
        return Post.objects.filter(
            tags__in=self.tags.all()
        ).exclude(id=self.id).distinct()[:limit]

Alternatively, handle this in the view:

from django.shortcuts import render, get_object_or_404

def post_detail(request, slug):
    post = get_object_or_404(Post, slug=slug)
    related_posts = Post.objects.filter(
        tags__in=post.tags.all()
    ).exclude(id=post.id).distinct()[:3]

    return render(request, 'blog/post_detail.html', {
        'post': post,
        'related_posts': related_posts
    })

Configure URLs

Set up the URL for the post detail view in urls.py:

from django.urls import path
from . import views

urlpatterns = [
    path('post/<slug:slug>/', views.post_detail, name='post_detail'),
]

Display related articles in blog/post_detail.html:

{% extends 'base.html' %}

{% block content %}
  <h1>{{ post.title }}</h1>
  <p>{{ post.content }}</p>

  <h2>Related Articles</h2>
  <ul>
    {% for related_post in related_posts %}
      <li><a href="{% url 'post_detail' related_post.slug %}">{{ related_post.title }}</a></li>
    {% empty %}
      <li>No related articles found.</li>
    {% endfor %}
  </ul>
{% endblock %}

Display and Filter by Tags

Show tags on posts and allow filtering by tag in the post list view.

Template (blog/post_detail.html):

<p>Tags:
  {% for tag in post.tags.all %}
    <a href="{% url 'post_list' %}?tag={{ tag.slug }}">{{ tag.name }}</a>
  {% empty %}
    No tags
  {% endfor %}
</p>

View (post_list):

def post_list(request):
    posts = Post.objects.all()
    tag_slug = request.GET.get('tag')
    if tag_slug:
        posts = posts.filter(tags__slug=tag_slug)
    return render(request, 'blog/post_list.html', {'posts': posts})

URL:

urlpatterns = [
    path('', views.post_list, name='post_list'),
    path('post/<slug:slug>/', views.post_detail, name='post_detail'),
]

Enhancing the Tag System

Case-Insensitive Tags

Normalize tag names to lowercase and enforce case-insensitive uniqueness:

class Tag(models.Model):
    name = models.CharField(max_length=50, unique=True)
    slug = models.SlugField(max_length=50, unique=True, blank=True)

    def save(self, *args, **kwargs):
        self.name = self.name.lower()
        if not self.slug:
            self.slug = slugify(self.name)
        super().save(*args, **kwargs)

    class Meta:
        constraints = [
            models.UniqueConstraint(
                models.functions.Lower('name'),
                name='unique_tag_name'
            )
        ]

Tag Validation

Add validation to prevent invalid tags (e.g., empty names) using model validation.

Hierarchical Tags

Support tag hierarchies by adding a parent field:

class Tag(models.Model):
    name = models.CharField(max_length=50, unique=True)
    slug = models.SlugField(max_length=50, unique=True, blank=True)
    parent = models.ForeignKey('self', null=True, blank=True, on_delete=models.CASCADE)

Rank related posts by the number of shared tags:

from django.db.models import Count

class Post(models.Model):
    # ... other fields ...

    def get_related_posts(self, limit=3):
        return Post.objects.filter(
            tags__in=self.tags.all()
        ).exclude(id=self.id).annotate(
            shared_tags=Count('tags')
        ).order_by('-shared_tags').distinct()[:limit]

Custom Tag Model vs. django-taggit

Here’s how a custom tag model compares to django-taggit:

Feature Custom Tag Model django-taggit
Ease of Setup More setup (define models, relationships) Quick setup with TaggableManager
Customization Highly customizable Limited without subclassing
Dependencies None Requires django-taggit
Generic Tagging Specific to models Tags any model via GenericForeignKey
Built-in Features Manual implementation Tag clouds, autocomplete, etc.
Maintenance Your responsibility Community-maintained

A custom tag model is ideal if you need specific features or prefer a dependency-free solution, while django-taggit suits rapid development with built-in functionality.


Conclusion

Implementing related articles in a Django blog app using a custom tag model is a powerful way to enhance user experience while maintaining full control over your tagging system. By defining a Tag model, linking it to posts via a many-to-many relationship, and querying shared tags, you can efficiently suggest relevant content. This approach is lightweight, flexible, and extensible, allowing for advanced features like case-insensitive tags, hierarchies, or ranked related posts. Whether you choose a custom model or django-taggit, the result is a more engaging blog that keeps readers exploring.