@ParamConverter à la Django
###########################

:date: 2015-06-07T18:14:32Z
:category: blog
:tags: python,django
:url: 2015/06/07/paramconverter-a-la-django/
:save_as: 2015/06/07/paramconverter-a-la-django/index.html
:status: published
:author:  Gergely Polonkai

One thing I really miss from `Django <https://www.djangoproject.com/>`_ is `Symfony
<http://symfony.com/>`_’s `@ParamConverter
<http://symfony.com/doc/current/bundles/SensioFrameworkExtraBundle/annotations/converters.html>`_. It
made my life so much easier while developing with Symfony.  In Django, of course, there is
`get_object_or_404
<https://docs.djangoproject.com/en/dev/topics/http/shortcuts/#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
<http://openclassrooms.com/forum/sujet/middleware-django-genre-paramconverter-doctrine>`_ (in
French), but it was very generalized that I cannot always use.  Also, it was using a middleware,
which may introduce performance issues sometimes :sup:`[citation needed]`.  So I decided to go
with decorators, and at the end, I came up with this:

.. code-block:: python

   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``.
