gergelypolonkai-web-jekyll/content/blog/2015-06-07-paramconverter-a...

155 lines
6.9 KiB
ReStructuredText
Raw Permalink Normal View History

@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 models 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 models 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 doesnt 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, lets assume
# its 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 doesnt 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``.