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

155 lines
6.9 KiB
ReStructuredText
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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