April 8, 2019

Django: Keeping logic out of templates (and views)

When I first started dabbling with Django and web-development, a good friend with a little more experience advised that I should keep logic away from my templates. Templates should be dumb”.

I didn’t really understand what that meant until I started suffering the consequences of having logic in my .html files. After 3 years with Django, I now try to keep business-logic away not only from templates, but also from views.

In this post I’ll gradually go over from the least to the most recommended path and outline the advantages that each one offers.

Our app: a simple blog

Let’s start with extracting logic from the templates first. As is the case with most real-world apps, the project usually starts simple and plain in its specifications and requirements, and starts growing gradually.

Given this model:

# models.py
from django.db import models
from django.utils import timezone


class Post(models.Model):
    title = models.CharField(max_length=90, blank=False)
    content = models.TextField(blank=False)
    slug = models.SlugField(max_length=90)
    is_draft = models.BooleanField(default=True, null=False)
    is_highlighted = models.BooleanField(default=False)
    published_date = models.DateTimeField(default=timezone.now)
    likes = models.IntegerField(default=0)

    class Meta:
        ordering = ('-published_date',)

    def __str__(self):
        return self.title

    @property
    def is_in_past(self):
        return self.published_date < timezone.now()

The worst: logic in templates

In our blog’s index.html, we want to display the latest 10 posts’ titles and their publication date. The title should also be a link to the post-detail view, where the post content is presented.

While we do want to see our drafts so we can preview how they look on the website, we certainly don’t want them visible to other visitors.

# views.py
def all_posts(request):
    context = {}
    posts = Post.objects.all()[:10]
    context['posts'] = posts
    return render(request, 'index.html', context)
{# index.html #}
{% for post in posts %}
  {% if request.user.is_superuser %}
    <div class="post-section">
      <h4>
        <a href="{% url 'post-detail' pk=post.id %}">{{ post.title }}</a>

        {% if post.is_draft %}
          <span class="alert alert-info small">Draft</span>
        {% endif %}

        {% if not post.is_in_past %}
          <span class="alert alert-info small">Future Post</span>
        {% endif %}
      <span class="text-muted"> Date: {{ post.published_date }}</span>
      </h4>
    </div>
  {% elif not request.user.is_superuser and not post.is_draft %}
    <div class="post-section">
      <h4>
        <a href="{% url 'post-detail' pk=post.id %}">{{ post.title }}</a>
      </h4>
      <span class="text-muted"> Date: {{ post.published_date }}</span>
    </div>
  {% endif %}
{% endfor %}

In index.html, we’re checking if request.user is an admin, and if they are, we’re not filtering any posts. in the elif block that applies to all other visitors, we’re making sure the is_draft property is False before displaying the post:

{% elif not request.user.is_superuser and not post.is_draft %}

We’re also adding some Bootstrap markup so an admin can see clearly if a certain post is a draft or one that is scheduled in the future. We don’t need this markup for regular visitors because they’re not supposed to see these posts in the first place.

This kind of design is pretty bad for several reasons:

  1. No separation of concerns: why is the template deciding which posts to show?
  2. Violates the DRY (Don’t Repeat Yourself) principle: look at the span tag that holds the date. Because of our choice, we have to repeat it in both clauses of our if statement.
  3. Verbosity: our index.html is only displaying links to our posts, yet it already feels very cluttered.
  4. Readability and maintainability: the Jinja/Django templating engine is good, but isn’t known for its clean syntax. If you come back to this in 6 months, can you quickly tell what’s happening? will you remember that if you add a div containing the post’s author name, you should do it in both clauses the if statement?

The better way

If instead we write our view like this:

# views.py
def posts_index(request):
    context = {}
    limit = 10
    posts = Post.objects.all()
    
    if not request.user.is_superuser:
        # hide drafts
        posts = posts.filter(is_draft=False)
    
    context['posts'] = posts[:limit]
    return render(request, 'index.html', context)

Then our index.html file looks like this:

{# index.html #}
{% for post in posts %}
  <div class="post-section">
    <h4>
      <a href="{% url 'post-detail' pk=post.id %}">{{ post.title }}</a>

      {% if post.is_draft %}
        <span class="alert alert-info small">Draft</span>
      {% endif %}

      {% if not post.is_in_past %}
        <span class="alert alert-info small">Future Post</span>
      {% endif %}
    </h4>
    <span class="text-muted"> Date: {{ post.published_date }}</span>
  </div>
{% endfor %}

We keep the business logic outside of the template file, as it should be strictly responsible for presentation 90% of the time. Templates should mostly be concerned with how elements are rendered, not which, or if they are.

What we gain here:

  • DRYness: we’re no longer repeating the HTML for rendering the post.
  • Reusability: because index.html no longer makes a decision about whether to display a post, we can use it in other views later (archive for example).
  • Readability: it’s much clearer now what’s happening in index.html and it’ll be easier to figure out when we come back to it in the future.

So this is much better, and probably sufficient if you’re developing a super-simple application. But even with this, you’ll start repeating yourself sooner than later.

You may have spotted a bug in the code above. We’re not filtering out future posts (those with a published_date value in the future) when we render the index to the blog’s visitors.

Let’s fix that:

# views.py
from django.utils import timezone

def posts_index(request):
    context = {}
    limit = 10
    posts = Post.objects.all()[:limit]

    if not request.user.is_superuser:
        # filter out drafts and future posts 
        posts = Post.objects.filter(is_draft=False, published_date__lte=timezone.now())[:limit]

    context['posts'] = posts
    return render(request, 'index.html', context)

Now only the admin will see future posts.

Now, we create a new view, featured_posts, where we only want to display posts that are marked as highlighted by us, using the is_highlighted field on in model. Simple enough:

def featured_posts(request):
    context = {}
    posts = Post.objects.filter(is_highlighted=True)

    if not request.user.is_superuser:
        posts = posts.filter(is_draft=False, published_date__lte=timezone.now())

    context['posts'] = posts
    # we're free to use `index.html` here because our template is now re-usable
    return render(request, 'index.html', context)

Now let’s create a third view, dashboard, where we display the latest 5 regular posts, and the latest 5 highlighted posts (they may overlap):

def dashboard(request):
    context = {}
    posts = Post.objects.all()
    limit = 10
    posts_featured = Post.objects.filter(is_highlighted=True)

    if not request.user.is_superuser:
        posts = posts.filter(is_draft=False, published_date__lte=timezone.now())
        posts_featured = posts_featured.filter(is_draft=False, published_date__lte=timezone.now())

    context['last_posts'] = posts[:limit]
    context['last_posts_featured'] = posts_featured[:limit]

    return render(request, 'dashboard.html', context)

We already see two problems here:

  1. Our code is getting more and more verbose, and that’s with only two fields to filter by. Imagine having 3 or 4 (like author and tags for example). With real-world applications you’ll often have more.
  2. We’re leaking implementation details of our models to our views: our view now has to know that there’s a field called is_highlighted in our models.

Worse yet, consider what happens if we now decide that posts appearing under the featured sections in our blog should meet two criteria:

  • is_published is True
  • likes count is at least 3

We now have to update the code in two of our views so it includes the new criterion:

Post.objects.filter(is_draft=False, is_highlighted=True, likes__gte=3)

Now imagine the work involved when you have 7 views, and two more criteria to filter by - definitely a possibility when you’re dealing with larger scale apps.

The even better way(s)

There are two ways to go about this. We’ll quickly cover the first one, which is considered less conventional and less natural, but does the job fine if you need something quick and dirty.

Class methods

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

    @classmethod
    def published(cls):
        """
        :return: published posts only: no drafts and no future posts
        """
        return cls.objects.filter(is_draft=False, published_date__lte=timezone.now())

    @classmethod
    def featured(cls):
        """
        :return: featured posts only
        """
        return cls.objects.filter(is_highlighted=True)

We’ve added two model methods, which we can use in our views like this:

# notice: no .objects because it's model/class method

published_posts = Post.published()
featured_posts = Post.featured()
published_and_featured = Post.published() &amp; Post.featured()

Look at how much cleaner our dashboard becomes with this change:

def dashboard(request):
    context = {}
    posts = Post.objects.all()
    limit = 10
    posts_featured = Post.featured()

    if not request.user.is_superuser:
        posts = posts &amp; Post.published()
        posts_featured = posts_featured &amp; Post.published()

    context['last_posts'] = posts[:limit]
    context['last_posts_featured'] = posts_featured[:limit]

    return render(request, 'dashboard.html', context)

What’s more, changing our criteria for what is considered a featured” post becomes as simple as changing one line in Post.featured():

class Post(model.Model):
    # ...
    @classmethod
    def featured(cls):
        """
        :return: highlighted posts with at least 3 likes
        """
        return cls.objects.filter(is_highlighted=True, likes__gte=3)

Now all the views that invoke this model method will update accordingly.

So this is pretty sweet, but as I wrote, considered less conventional in the Django community. One more limitation of model methods is that they are not directly chainable:

# attempting to chain our two methods
>>> posts_featured_published = Post.featured().published()

'QuerySet' object has no attribute 'published'

This is why we turn to using the logical AND (&amp;) operator:

# using '&amp;' to further filter our queryset
posts_featured_published = Post.featured() &amp; Post.published()

So using model methods solves many of the previous method’s shortcomings, but there’s an even better way.

Custom model managers

I’m not going to go in-depth about managers vs querysets, as this is beyond the scope of this post. Let’s get rid of our model methods in the previous step, and instead define our models.py file like this:


class PostQuerySet(models.QuerySet):
    def published(self):
        return self.filter(is_draft=False, published_date__lte=timezone.now())

    def featured(self):
        return self.filter(is_highlighted=True)


# Create your models here.
class Post(models.Model):
    title = models.CharField(max_length=90, blank=False)
    content = models.TextField(blank=False)
    slug = models.SlugField(max_length=90)
    is_draft = models.BooleanField(default=True, null=False)
    is_highlighted = models.BooleanField(default=False)
    published_date = models.DateTimeField(default=timezone.now)
    likes = models.IntegerField(default=0)

    # use PostQuerySet as the manager for this model
    objects = PostQuerySet.as_manager()

    class Meta:
        ordering = ('-published_date',)

    def __str__(self):
        return self.title

    @property
    def is_in_past(self):
        return self.published_date < timezone.now()

Of note is the objects field we’ve added to Post, which instructs this model to use PostQuerySet as its manager.

Let’s examine, once again, our dashboard view:

def dashboard(request):
    context = {}
    posts = Post.objects.all()
    limit = 10
    posts_featured = Post.objects.featured()

    if not request.user.is_superuser:
        posts = posts.published()
        posts_featured = posts_featured.published()
        
    context['last_posts'] = posts[:limit]
    context['last_posts_featured'] = posts_featured[:limit]

    return render(request, 'dashboard.html', context)

Notice how we these two manager methods are now chainable:

>>> posts_featured_published = Post.objects.featured().published()

<PostQuerySet [<Post: ...>, <Post: ...>]>

With PostQuerySet in our models.py file, we’re extending the manager-methods at our disposal, so alongside get, filter, aggregate, etc…we now have published and featured.

A few advantages of using model managers over class methods:

  1. Chainability and clarity: Post.objects.featured().published() looks more pythonic and natural than Post.featured() &amp; Post.published().
  2. Reusability: in many cases you can reuse the same manager for more than one model. Maybe in the future you’ll create a ShortNote model which you can use the same PostQuerySet to manage. With model methods you’ll have to redefine custom filters inside your ShortNote model.

There are a few more advantages, such as the ability to define several managers on the same model, but these are beyond the scope of this post.

So, takeaway: keep logic out of templates almost at all costs, try to have as little of it as possible in your views. If you want something quick, a model method may suffice, but prefer model managers.


Previous post
Django templates: 'include' context Something I learned today which should come handy. The tag allows rendering a partial template from another: So I was doing this to pass context to
Next post
macOS migrations with Brewfile Perhaps the most-dreaded aspect of setting-up a new machine is the time spent on reinstalling apps and reapplying all of the customizations from the