Thursday, August 09, 2012

Django's extended user profile...

Django, as Python's (arguably) most popular web framework, follows the 'batteries included' philosophy of the language with a large number of built-in web features. One of these is the 'auth' system, which provides simple user and group authentication and permissions management.

The core User class is 'skeletal' in nature - containing as it does just email, username, first name, last name and password profile fields (as well as some other utility attributes such as 'is_staff' etc). This is unlikely to ever satisfy a real-world scenario, and so Django includes a documented mechanism for extending this base user record with your own application-specific extended profile. It's simple to set up and use - you define a profile model, and then add that model as the AUTH_PROFILE_MODULE setting. Once this is set up, you can simply call the get_profile() method of any user object, and it will return the profile object.

Unfortunately, there is one fundamental limitation to this pattern - it is assumed that every user registering on your site has the same profile. So if, for instance, you wanted teachers and pupils, or perhaps suppliers and customers; or buyers and sellers, each with their own profile type, you are out of luck.

Google this particular issue and you will find a plethora of workarounds and hacks, all of which seem to start with the same underlying assumption - that the core django framework does some kind of magic that cannot be subverted; get_profile() is sacrosanct.

One of the joys of using open source software, of course, is that you can open up frameworks and just take a look at how they work. So that's what I've done, and this is what I've found...

When using extended user profiles within django, there are really only two key requirements - first, that when a new user signs up, the user profile is created; and second, that when you call get_profile() on a user, you get the correct profile object back again. Let's dig into both of those in turn, and consider how we might adapt them to use multiple profile types.

1. Creating the user profile.

When creating the extended user profile, the django framework provides precisely no assistance. It is entirely up to the application developer to manage this process. To quote from the docs:
The method get_profile() does not create a profile if one does not exist. You need to register a handler for the User model'sdjango.db.models.signals.post_save signal and, in the handler, if created is True, create the associated user profile:
So, in this case, it makes no difference whether you have one or more user profile classes to use - it's entirely up to you. You would probably have something similar to this in your views:

    if kwargs['type']=='teacher':
        profile = TeacherProfile(user=request.user, subject=kwargs['subject'])
    elif kwargs['type']=='student':
        profile = StudentProfile(user=request.user, year=kwargs['year'])
    profile.save()

2. Retrieving the user profile.

This is where django, in theory, starts to add value. The framework includes the user.get_profile() method, which will return the relevant profile object for the user. The problem being that it will only support one, fixed, profile class for all users in the system. This is where the source code helps us:

    def get_profile(self):
        """
        Returns site-specific profile for this user. Raises
        SiteProfileNotAvailable if this site does not allow profiles.
        """
        if not hasattr(self, '_profile_cache'):
            from django.conf import settings
            if not getattr(settings, 'AUTH_PROFILE_MODULE', False):
                raise SiteProfileNotAvailable(
                    'You need to set AUTH_PROFILE_MODULE in your project '
                    'settings')
            try:
                app_label, model_name = settings.AUTH_PROFILE_MODULE.split('.')
            except ValueError:
                raise SiteProfileNotAvailable(
                    'app_label and model_name should be separated by a dot in '
                    'the AUTH_PROFILE_MODULE setting')
            try:
                model = models.get_model(app_label, model_name)
                if model is None:
                    raise SiteProfileNotAvailable(
                        'Unable to load the profile model, check '
                        'AUTH_PROFILE_MODULE in your project settings')
                self._profile_cache = model._default_manager.using(
                                   self._state.db).get(user__id__exact=self.id)
                self._profile_cache.user = self
            except (ImportError, ImproperlyConfigured):
                raise SiteProfileNotAvailable
        return self._profile_cache

Observation #1 is that it's not a very complicated method; we should be able to work out what this does. Let's remove some of the error checking and reduce it to what we really want:

    def get_profile(self):
        from django.conf import settings
        app_label, model_name = settings.AUTH_PROFILE_MODULE.split('.')
        model = models.get_model(app_label, model_name)
        return model._default_manager.using(
             self._state.db).get(user__id__exact=self.id)

Now let's reduce that to pseudocode:

        # pseudocode - do not attempt to run!
   def get_profile(self):

        determine_model_used_as_profile_form_settings_file()
        get_model_from_db_using_current_user_id()

Observation #2 is that this is trivial - it does exactly what you would think, and no more. There is nothing to be scared of here. The only problem is that is only expects one value to exist in the settings file, and is fixed on that assumption.

So, how do we take this knowledge and use it to our advantage. My own personal view is that, having read through the code, you should feel perfectly happy and comfortable to ignore it, and roll your own. It really isn't doing anything magic. Nothing will break.

Using our teacher/student app above, you can simply create your own get_profile() method, and hard-code the 'get' code (you can stick the method anywhere you feel comfortable using it - I have a profiles app that defines the models, and have the get_profiles() method in __init__.py file):

        # pseudocode - do not attempt to run!
   def get_profile2(user):


        if user is teacher:
           return get_teacher_profile(user_id = user.id)
        elif user is student:
           return get_student_profile(user_id = user.id)



The obvious problem in the pseudocode above is how to check if the user is a teacher or student, given that there is nothing on the user object that we can use to determine what type the user is. The simplest answer to this, and it is a hack, is to use Groups. By assigning users to relevant groups when they register you can do the following:


    def get_profile2(user):
        if user.groups.filter(name='teachers').exists():
           return get_teacher_profile(user_id = user.id)
        elif user.groups.filter(name='students').exists():
           return get_student_profile(user_id = user.id)

This is a workable solution, but it does require hard-coding the groups, which makes it more of a copy-and-paste solution than a pluggable django app or extension.

The ideal answer is for the core django project to support multiple profile types within a project, by changing the way it currently works. The fix is trivial:
  1. Change the AUTH_PROFILE_MODULE to use a dictionary instead of a string to store a mapping of user types and their profile models
  2. Add an additional field to the core user model, called something like 'user_type' (or, heaven forbid, 'profile')
  3. Update auth.models.User.get_profile() to look up the model based on the user_type / profile attribute and use that instead
I have hacked my local copy of django to do just this, and this method works as expected. A fully-integrated, multi-profile, django installation. Of course, this isn't supportable by any hosting company, so is not a viable short-term solution. In fact it's only supportable if it gets pulled into the next version of django. I'd love to see this happen, so if you think this is a good idea, please add any feedback below; if enough people want it to happen...