Revisited: Common fields, model subclassing

One of the first issues I ran into when I started with Django was the lack of support for model inheritance. Not true model inheritance on the SQL level, but just the possibility to inherit from other models in Python to reuse field definitions (i.e. copy them). Finally, I came up with this snippet, which exploited the fact that models can actually have non-model bases, and just added the shared fields to a model in a separate function call.

jmrbcu eventually posted a much better version that I’ve been using every since. There were still some problems with it though:

  • It didn’t work with new-style base classes.
  • The fields were not inserted at the position of the expander field, but always either at the beginning or the end of the model.
  • I still wasn’t 100% happy with the fake expander fields that were required, especially since you had to use different names if you wanted to do multiple expansions (common1, common2, …). You also had to repeat the class name of the mixin, once as a base class for the model, and again when calling the expander field.

The following code attempts to fix all of those:

import types
from copy import deepcopy
from django.db import models
from django.db.models.fields import Field
from django.db.models.base import ModelBase

class ExpanderField(object):
    def __init__(self, *field_container_classes):
        self.field_container_classes = field_container_classes

    def contribute_to_class(self, cls, name):
        for container in self.field_container_classes:
            for name, value in clone_class(container):
                cls.add_to_class(name, value)

def use_bases(*classes):
    class ExpandableModelBase(ModelBase):
        def __new__(cls, name, bases, attrs):
            bases = list(bases)
            for klass in reversed(classes):
                # Add as a base class to allow method overriding. MRO
                # requires us to list the mixins *before* models.Model.
                bases.insert(0, klass)
                # Insert copies of all attributes
                for attr_name, attr_value in clone_class(klass):
                    # Make sure we do not hide any existing attributes: The
                    # mixins are supposed to act as a base classes after all.
                    # TODO: This currently works not as expected if multiple
                    # mixins define the same name, because of reversed().
                    if not attr_name in attrs:
                        attrs.update({attr_name: attr_value})
            return ModelBase.__new__(cls, name, tuple(bases), attrs)
    return ExpandableModelBase

def clone_class(klass):
    for attr in [attr for attr in dir(klass) if not attr.startswith('__')]:
        clone = None
        attr_value = getattr(klass, attr)

        if type(attr_value) != types.MethodType:
            clone = deepcopy(attr_value)

            # to have the fields in the right order, hack creation_counter.
            if isinstance(clone, Field):
                clone.creation_counter = Field.creation_counter
                Field.creation_counter += 1  # or create throwaway Field instance?

            # avoid ``related_name`` clashes
            if (isinstance(clone, models.ForeignKey) or
                isinstance(clone, models.ManyToManyField)) and 
                clone.rel.related_name is not None:
                clone.rel.related_name = cls.__name__ + '_' + clone.rel.related_name

        if clone is not None: yield attr, clone

If you care about field ordering, you can still expand fields manually, as in jmrbcu original snippet:

class CommonFields(object):
    created_by = models.ForeignKey(User)
    last_modified_by = models.ForeignKey(User)

    def save(self):
        # ...
        return super(CommonFields, self).save()

class NewsItem(CommonFields, models.Model):
    title = models.CharField()
    body = models.CharField()
    common = ExpanderField(CommonFields)

If you are ok with the fields being added at the end of the model, you can do:

class NewsItem(models.Model):
    __metaclass__ = use_bases(CommonFields)
    title = models.CharField()
    body = models.CharField()

For completeness sake, srid has taken this one step further, avoiding the __metaclass__ assignment by hacking the stack, although I think that is a but too much for my taste.

In case you are wondering what this is useful for, here are two examples of code that I currently have in use:

class Timestamped(object):
    created_at = models.DateTimeField(editable=False)
    updated_at = models.DateTimeField(editable=False)
    created_by = models.ForeignKey(User, editable=False, blank=True, null=True)

    def save(self):
        now = datetime.datetime.now()
        if not self.id:
            self.created_at = now
        self.updated_at = now
        super(Timestamped, self).save()

class Publication(models.Model):
    __metaclass__ = use_bases(Timestamped)
     # ...
class Issue(models.Model):
    __metaclass__ = use_bases(Timestamped)
     # ...

This is simple. Every model using this mixin will automatically keep track of when objects are created and changed. It also adds the “created_by“ field, but it has to be managed manually. I have this on almost all of my models.

class Approvable(object):
    is_approved = models.BooleanField(verbose_name="approved", editable=False,
        default=True)
    approved_at = models.DateTimeField(editable=False, blank=True, null=True)

    def __setattr__(self, name, value):
        # ignore the first calls when Field instances are still being
        # replaced by their plain values.
        if not isinstance(self.is_approved, models.Field):
            # if we are set to "approved", remember to update the timestamp
            if name == 'is_approved' and value != self.is_approved and value:
                self._date_needs_update = True
        return super(Approvable, self).__setattr__(name, value)

    def save(self):
        if self.is_approved:
            # for new items, always update
            if (not self.id): update_approved_date = True
            # for existing items, check if the value changed
            else:
                update_approved_date = getattr(self, '_date_needs_update', False)
                self._date_needs_update = False

            if update_approved_date:
                self.approved_at = datetime.datetime.now()

        super(Approvable, self).save()

class Review(models.Model):
    __metaclass__ = use_bases(Timestamped, Approvable)
     # ...

I have this applied to numerous models that require confirmation by an editor or administrator, like comments and generally stuff from users with limited rights.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s