Overriding the save method in Django - why, when, how?

Published byGoogle on

Hi and welcome back for this new Django tutorial. Last week, we have seen how to have a dynamic Bootstrap theme built from variables held in a database. Although this was not very efficient since the theme gets compiled every time it is loaded. This week we are going to see how to cache the compiled version of the theme inside the database and retrieve the cached version whenever we need it.

There are 2 ways of "doing something" when a model is saved in Django.

  • Signals
  • Overriding the concerned method

Signals are, in a nutshell, methods that get called when an event occur, in our case pre_save and post_save. In some frameworks the use of those signals is encouraged over overriding the method itself. This is not the case in Django.

Personnally, the way I decide whether to use signals or overriding the main method is simple:

  • Is this business logic only specific to this model? => override the main method
  • Can this business logic be used for other models as well? => use a signal

In this case, this is clearly a model-specific behaviour (do you really know of any other model which induces changes in css) so we are going to be overriding the main method.

When overriding a model method like save or delete, make sure to remember to call its superclass method to be certain operations will actually happen in the database.
The same way we don't have to rebuild the entire css every time our theme gets loaded, we also don't need to rebuild it every time it gets save. We only have to do it if brand_primary or body_bg are dirty (if they have been modified).
The way we are going to check for that is to load the object from database (unless it's a new record) before it gets saved, compare our instance to save with the data retrieved from the database and update the cache field only if needed before actually saving the object.

To check if an object is a new record, there is a simple method which consist of checking whether its primary key is set. This method only works with automatic primary keys, If you have overriden your model definition and used another field as primary key, this won't work and you'll have to find your own way of checking if the object is new or not.

In our case the primary key field is id, but Django offers another property on models called pk. Pk always holds the primary key whether it's name is id or anything else.

Let's add the cache field to our model and move the css-generating code from the view. It will get called only when needed:

from django.db import models
from django.core.validators import RegexValidator

## Those imports are the ones needed to build the css
import os
import fnmatch

import scss

from django.conf import settings
from django.utils.datastructures import SortedDict
from django.contrib.staticfiles import finders

## The sass compiling code moved from the view

def finder(glob):
    """
    Finds all files in the django finders for a given glob,
    returns the file path, if available, and the django storage object.
    storage objects must implement the File storage API:
    https://docs.djangoproject.com/en/dev/ref/files/storage/
    """
    for finder in finders.get_finders():
        for path, storage in finder.list([]):
            if fnmatch.fnmatchcase(path, glob):
                yield path, storage


# STATIC_ROOT is where pyScss looks for images and static data.
# STATIC_ROOT can be either a fully qualified path name or a "finder"
# iterable function that receives a filename or glob and returns a tuple
# of the file found and its file storage object for each matching file.
# (https://docs.djangoproject.com/en/dev/ref/files/storage/)
scss.config.STATIC_ROOT = finder
scss.config.STATIC_URL = settings.STATIC_URL

# ASSETS_ROOT is where the pyScss outputs the generated files such as spritemaps
# and compile cache:
scss.config.ASSETS_ROOT = settings.MEDIA_ROOT
scss.config.ASSETS_URL = settings.MEDIA_URL

# These are the paths pyScss will look ".scss" files on. This can be the path to
# the compass framework or blueprint or compass-recepies, etc.
BASE_DIR = os.path.dirname(os.path.dirname(__file__))
scss.config.LOAD_PATHS = [
    os.path.join(BASE_DIR, 'sass', 'css'),
]


class Theme(models.Model):
  name = models.CharField('Theme name', max_length=50)
  slug = models.SlugField(unique=True, max_length=50)
  brand_primary = models.CharField(max_length=7, validators=[RegexValidator(
      r'^#[0-9a-fA-F]{3,6}$',
      'Only hex colors are allowed',
      'Invalid color')]
  )
  body_bg = models.CharField(max_length=7, validators=[RegexValidator(
      r'^#[0-9a-fA-F]{3,6}$',
      'Only hex colors are allowed',
      'Invalid color')]
  )
  cache = models.TextField(null=True, blank=True)

  ## This changed: we don't need to retrieve the theme from database, this is now done directly inside the object 
  def build_css(self):
    _scss_vars = {}
    compiler = scss.Scss(
        scss_vars=_scss_vars,
        scss_opts={
            'compress': True,
            'debug_info': False,
        }
    )

    main = """
        $brand-primary = %s;
        $body-bg = %s;
        @import "bootstrap";
        """ % (self.brand_primary, self.body_bg)

    return compiler.compile(main)

  def save(self, *args, **kwargs):
    update_cache = True  ## by default we have to generate css
    if self.pk is not None:
      update_cache = False  ## if this is an updated record, maybe we don't have to
      fields_to_check = ['brand_primary', 'body_bg']
      orig = Theme.objects.get(pk=self.pk)
      for field in fields_to_check:
        if (getattr(orig, field) != getattr(self, field)):
          update_cache = True ## at least one of the checked fields has changed, we need to update cache
          break ## Our record is dirty, no need to keep going
      if not update_cache and (orig.cache is None or orig.cache == ''):
        update_cache = True
    if update_cache:
      self.cache = self.build_css()

    super(Theme, self).save(*args, **kwargs);

Of course, our view is going to get much leaner (and faster) now since it's only going to get the cache:

from django.http import HttpResponse, Http404
from django.shortcuts import get_object_or_404

from theme.models import Theme


def css(request, slug=None):
  theme = get_object_or_404(Theme, slug=slug)
  css = theme.cache
  if css is None or css == '':  ## Just making sure
    css = theme.build_css()
  return HttpResponse(css, content_type="text/css")

Try it (don't forget to migrate your database first) and see how the cache is built upon saving the Theme.
Now there is just one little thing left for us to do; you noticed that the cache field appeared in the admin while it should not (its content is automatically generated and should not be messed with). We will ask the admin not to show us this field:

from django.contrib import admin
from theme.models import Theme
                                                                                                                      
class ThemeAdmin(admin.ModelAdmin):
  
  prepopulated_fields = {"slug":("name",)}
  fields = ('name', 'brand_primary', 'body_bg', 'slug')                                                               
    
admin.site.register(Theme, ThemeAdmin)

As you can see, the fields attribute of Django's ModelAdmin lets us decide which fields are going to be shown or not. You might also want to check its fieldsets attribute.

Well, that's it for this week. In a few weeks, we will finally see how to create our custom users and register them using social sites.
In the meantime, happy coding everyone Smile

As always, you can browse the code for this tutorial on http://vc.lasolution.be/projects/babbler-tutorial/repository.
The code is available for checkout on http://code.lasolution.be/babbler.
The branch associated with this tutorial is overriding-save-method-django-why-when-how

Tutorial Django

Comments

How do we override the same save method outside models.py?

By Utkarsh Jadhav - 2 years, 4 months ago Reply 

I'm not sure what you exactly mean by that but if you are referring to monkey patching, we can always do something like that:

from app.models import TheModel

TheModel.save = my_new_save_method

I hope this helps

By Emmanuelle Delescolle - 2 years, 4 months ago Reply 

Post a comment