Subscribe

Django custom user models, South, and reusable apps

While working on django-chalk I ran into an issue with the way South migrations and Django 1.5's custom user models can interact. South examines your models when you create a migration and then stores them as a hard-coded dictionary inside the migration. This is fine when you know that models will be identical wherever your migration is run, but if you're writing a reusable app that isn't always an assumption you can make.

Reusable apps don't generally deal with models which might change from one installation to another. Your models are either connected to other models within the same app or to core/contrib Django models whose structure you can rely on.

The custom User model introduced in Django 1.5, however, complicates matters. It isn't uncommon for a reusable app's models to need foreign keys to some kind of user model. You don't necessarily care about the details of that model, but any migrations you create will contain a frozen version of your user model, and that can create problems when people try to use your app in a project with a custom user model.

I worked out a solution by looking at how Mezzanine is handling migrations, but since I'm obviously not the first to encounter this and I couldn't find a write up about it anywhere I thought it might be helpful to put one together. All of this applies to South 0.8.2 and it's possible that it won't be a problem for future versions.

A practical example

Let's say that our model looks like this:

# chalk/models.py
from django.conf import settings
from django.db import models

# Backwards compatible settings.AUTH_USER_MODEL
auth_user_model = getattr(settings, 'AUTH_USER_MODEL', 'auth.User')

class Article(models.Model):
    author = models.ForeignKey(auth_user_model)
    title = models.CharField(max_length=250)
    content = models.TextField()

We don't care about the user model's structure, we just want to be able to associate articles with an author. At some point we decide that we need to add a field to our model, so we'll set up an initial migration.

Here are the relevant pieces of the migration South creates for us:

# chalk/migrations/0001_initial.py
from south.db import db
from south.v2 import SchemaMigration

class Migration(SchemaMigration):
    def forwards(self, orm):
        db.create_table(u'chalk_article', (
            (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
            # Hard-coded 'auth.User' here will be a problem for anyone
            # using a custom user model
            ('author', self.gf('django.db.models.fields.related.ForeignKey')
                (to=orm['auth.User'])),
            ('title', self.gf('django.db.models.fields.CharField')(max_length=250)),
            ('content', self.gf('django.db.models.fields.TextField')()),
        ))
        db.send_create_signal(u'chalk', ['Article'])

    models = {
        # Again, if this migration is run somewhere with a custom user model
        # 'auth.user' will be a problem.
        u'auth.user': {
            'Meta': {'object_name': 'User'},
            # If the custom user model's primary key doesn't match this
            # declaration the migration will fail
            u'id': ('django.db.models.fields.AutoField', [],
                {'primary_key': 'True'}),
            # ...
            # Other model fields removed for brevity
            # ...
        },
        u'chalk.article': {
            'Meta': {'object_name': 'Article'},
            # Hard-coded 'auth.User' again
            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
            'author': ('django.db.models.fields.related.ForeignKey',
                [], {'to': u"orm['auth.User']"}),
            'content': ('django.db.models.fields.TextField', [], {}),
            'title': ('django.db.models.fields.CharField', [], {'max_length': '250'})
        }
    }

    complete_apps = ['chalk']

Notice that Django's standard user model has been frozen because I created the migration from within a project using the default model. This migration will fail for anybody who tries to migrate to a new version of our app if they've replaced the default user model. But since our app doesn't actually care about the details of the user model it seems silly to sacrifice portability like this.

So let's make our migration more flexible:

# Updated chalk/migrations/0001_initial.py
from south.db import db
from south.v2 import SchemaMigration

# Safe User import for Django < 1.5
try:
    from django.contrib.auth import get_user_model
except ImportError:
    from django.contrib.auth.models import User
else:
    User = get_user_model()

# With the default User model these will be 'auth.User' and 'auth.user'
# so instead of using orm['auth.User'] we can use orm[user_orm_label]
user_orm_label = '%s.%s' % (User._meta.app_label, User._meta.object_name)
user_model_label = '%s.%s' % (User._meta.app_label, User._meta.module_name)

class Migration(SchemaMigration):
    def forwards(self, orm):
        db.create_table(u'chalk_article', (
            (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
            ('author', self.gf('django.db.models.fields.related.ForeignKey')
                (to=orm[user_orm_label])),
            ('title', self.gf('django.db.models.fields.CharField')(max_length=250)),
            ('content', self.gf('django.db.models.fields.TextField')()),
        ))
        db.send_create_signal(u'chalk', ['Article'])

    models = {
        # We've accounted for changes to:
        # the app name, table name, pk attribute name, pk column name.
        # The only assumption left is that the pk is an AutoField (see below)
        user_model_label: {
            'Meta': {
                'object_name': User.__name__,
                'db_table': "'%s'" % User._meta.db_table
            },
            User._meta.pk.attname: (
                'django.db.models.fields.AutoField', [],
                {'primary_key': 'True',
                'db_column': "'%s'" % User._meta.pk.column}
            ),
        },
        u'chalk.article': {
            'Meta': {'object_name': 'Article'},
            'author': ('django.db.models.fields.related.ForeignKey', [],
                {'to': "orm['%s']" % user_orm_label}),
            'content': ('django.db.models.fields.TextField', [], {}),
            u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
            'title': ('django.db.models.fields.CharField', [], {'max_length': '250'})
        }
    }

    complete_apps = ['chalk']

The point of these changes is to replace the hard-coded values South found when we created the migration with dynamic values based on the migration's runtime environment. We've also removed most of the fields in the frozen user model because we don't care about them - in this particular case we're only concerned with the primary key because it's referenced by Article.author.

The only thing that we're assuming about the user model at this point is that its primary key is an AutoField, and we could probably determine that at runtime by either using the inspect module or by examining User._meta.pk.__class__, but I haven't had time to test that properly .