@ParamConverter à la Django

One thing I really miss from Django is Symfony’s @ParamConverter. It made my life so much easier while developing with Symfony. In Django, of course, there is get_object_or_404 but, for example, in one of my projects I had a view that had to resolve 6(!) objects from the URL, and writing get_object_or_404 six times is not what a programmer likes to do (yes, this view had a refactor later on). A quick Google search gave me one usable result (in French), but it was very generalized that I cannot always use. Also, it was using a middleware, which may introduce performance issues sometimes [citation needed]. So I decided to go with decorators, and at the end, I came up with this:

import re

from django.shortcuts import get_object_or_404
from django.db import models

def convert_params(*params_to_convert, **options):
    """
    Convert parameters to objects. Each parameter to this decorator
    must be a model instance (subclass of django.db.models.Model) or a
    tuple with the following members:
    * model: a Model subclass
    * param_name: the name of the parameter that holds the value to be
      matched. If not exists, or is None, the model’s class name will
      be converted from ModelName to model_name form, suffixed with
      "_id". E.g. for MyModel, the default will be my_model_id
    * the field name against which the value in param_name will be
      matched. If not exists or is None, the default will be "id"
    * obj_param_name: the name of the parameter that will hold the
      resolved object. If not exists or None, the default value will
      be the model’s class name converted from ModelName to model_name
      form, e.g. for MyModel, the default value will be my_model.
    The values are resolved with get_object_or_404, so if the given
    object doesn’t exist, it will redirect to a 404 page. If you want
    to allow non-existing models, pass prevent_404=True as a keyword
    argument.
    """

    prevent_404 = options.pop('prevent_404', False)

    def is_model(m):
        return issubclass(type(m), models.base.ModelBase)

    if len(params_to_convert) == 0:
        raise ValueError("Must pass at least one parameter spec!")

    if (
            len(params_to_convert) == 1 and \
            hasattr(params_to_convert[0], '__call__') and \
            not is_model(params_to_convert[0])):
        raise ValueError("This decorator must have arguments!")

    def convert_params_decorator(func):
        def wrapper(*args, **kwargs):
            converted_params = ()
            for pspec in params_to_convert:
                # If the current pspec is not a tuple, let’s assume
                # it’s a model class
                if not isinstance(pspec, tuple):
                    pspec = (pspec,)

                # First, and the only required element in the
                # parameters is the model name which this object
                # belongs to
                model = pspec[0]

                if not is_model(model):
                    raise ValueError(
                        "First value in pspec must be a Model subclass!")

                # We will calculate these soon…
                param_name = None
                calc_obj_name = re.sub(
                    '([a-z0-9])([A-Z])',
                    r'\1_\2',
                    re.sub(
                        '(.)([A-Z][a-z]+)',
                        r'\1_\2',
                        model.__name__)).lower()
                obj_field_name = None

                # The second element, if not None, is the keyword
                # parameter name that holds the value to convert
                if len(pspec) < 2 or pspec[1] is None:
                    param_name = calc_obj_name + '_id'
                else:
                    param_name = pspec[1]

                if param_name in converted_params:
                    raise ValueError('%s is already converted' % param_name)

                converted_params += (param_name,)
                field_value = kwargs.pop(param_name)

                # The third element is the field name which must be
                # equal to the specified value. If it doesn’t exist or
                # None, it defaults to 'id'
                if (len(pspec) < 3) or pspec[2] is None:
                    obj_field_name = 'id'
                else:
                    obj_field_name = pspec[2]

                # The fourth element is the parameter name for the
                # object. If the parameter already exists, we consider
                # it an error
                if (len(pspec) < 4) or pspec[3] is None:
                    obj_param_name = calc_obj_name
                else:
                    obj_param_name = pspec[3]

                if obj_param_name in kwargs:
                    raise KeyError(
                        "'%s' already exists as a parameter" % obj_param_name)

                filter_kwargs = {obj_field_name: field_value}

                if (prevent_404):
                    kwargs[obj_param_name] = model.objects.filter(
                        **filter_kwargs).first()
                else:
                    kwargs[obj_param_name] = get_object_or_404(
                        model,
                        **filter_kwargs)

            return func(*args, **kwargs)

        return wrapper

    return convert_params_decorator

Now I can decorate my views, either class or function based, with @convert_params(User, (Article, 'aid'), (Paragraph, None, 'pid'), (AnotherObject, None, None, 'obj')) and all the magic happens in the background. The user_id parameter passed to my function will be popped off, and be resolved against the User model by using the id field; the result is put in the new user parameter. For Article, the aid parameter will be matched against the id field of the Article model putting the result into article, and finally, the another_object_id will be matched against the id field of the AnotherObject model, but in this case, the result is passed to the original function as obj.

contacts & more