wwd.ca

 

mon petit blogue sans importance...

Django templates, and proper decorators...

It all started with a very simple thing: make a decorator that will make a view only accessible by users who are part of a certain group. But it rapidly degenerated...


Firtly, i wanted to make the decorator a proper, signature-preserving decorator. That link explains the problem, and provides a solution; a potentially better solution is the wraps function from python's functools module. Unfortunately, that's only available from python 2.5, and we internally also support python 2.4 as a development platform. But fortunately, django has a custom version for those older pythons:

try:
    from functools import wraps
except ImportError:
    from django.utils.functional import wraps  # Python 2.3, 2.4 fallback.
Nice. So here's what our staff_required decorator looks like:
def staff_required(f):
    def decorator(request, *args, **kwargs):
        u = request.user
        if not u.is_authenticated() or not u.is_staff:
            raise PermissionDenied
        return f(request, *args, **kwargs)
    return wraps(f)(decorator)
Decorate a view with it, like so:
@staff_required
def community_activity_partial(request, template_name='EventFeed.html'):
    ...
And if the user isn't authenticated or isn't a staff member, he'll be presented with a nice 403. (I know, django has the _CheckLogin decorator that does that sort of thing. Problem is, it leads you to a login page if you don't pass the test, which is confusing to users, as they're already logged-in, and if they're not staff, they won't have a staff account, so there's no point to leading them to a login page! I want to convey the information that they just can't get there from here.)

Now, i was trying different ways of using that same decorator structure, but with the ability of passing an argument to the decorator, which is the group name that the user must be a part of for him to have access to the view. And then i tried it. The error i got when trying to reach any page was:

TemplateSyntaxError at /

Caught an exception while rendering: Reverse for 'akoha.akoha.cards.views.mission_factory' with arguments '()' and keyword arguments '{}' not found.
The problem was with this piece of code in the template:
{% url akoha.cards.views.mission_factory %}
Now, i knew we were using that tag all over the place. I googled a bit and found that, sure enough, you shouldn't put the project name in the argument to url, if it's the current project.... But then again, it'd been working forever... And why is it looking for akoha.akoha.cards.views.mission_factory??? The view is cards.views.mission_factory, the project is akoha, why the duplicate project name? Of course it won't find it! Lots of head scratching lead to looking up how the url tag is implemented; it's in django/template/defaulttags.py. Sure enough, it first tries to look up the view, and if it doesn't find it, will try prepending it with the current project name.

But the error message you then get has the project name in front of it, which if you had it already in the argument, will lead you to try to find WHY it's looking for project.project.app.view, when that is not the problem, and you should be looking at another problem entirely. And i'm still very perplexed, as the error is with an url tag that concerns a view that is not even the view i actually decorated...

Ok, so after having spent some time trying to figure out why this double project name prefix is not the problem, i open a python shell and try to do what the url tag does, which is:

In [1]: from django.core.urlresolvers import reverse
In [2]: reverse('cards.views.mission_factory')
and sure enough, here's the problem: i get a nice stack trace explaining why the reverse isn't working, which is that my decorator is all screwed up and upon importing the view module, which django needs to do to get to the other view, and applying the decorator, which it does right when importing the module, an exception is raised... which is an
ImportError
. Had i seen that import error in the very beginning in django's debugging 500 page, i would have clued in sooo much sooner... But here's the problem: the reverse function, when it can't import, doesn't just raise the exception it found (ImportError), it raises another exception, NoReverseMatch, which is the exception that trickles through the url tag, and then to the template... The reasoning must be that if it can't import the module, it's because the module doesn't exist, so the view can't be found... which is a bit, shall we say, retarded, because the more likely cause is actually that there was another problem importing, and the original stack trace is lost....

Here's the stack trace i saw:

Exception Type: TemplateSyntaxError at /
Exception Value: Caught an exception while rendering: Reverse for 'akoha.akoha.cards.views.mission_factory' with arguments '()' and keyword arguments '{}' not found.

Original Traceback (most recent call last):
  File "/home/eric/akoha/sandbox/akoha/site/lib/python2.5/site-packages/django/template/debug.py", line 71, in render_node
    result = node.render(context)
  File "/home/eric/akoha/sandbox/akoha/site/lib/python2.5/site-packages/django/template/defaulttags.py", line 381, in render
    args=args, kwargs=kwargs)
  File "/home/eric/akoha/sandbox/akoha/site/lib/python2.5/site-packages/django/core/urlresolvers.py", line 252, in reverse
    *args, **kwargs)))
  File "/home/eric/akoha/sandbox/akoha/site/lib/python2.5/site-packages/django/core/urlresolvers.py", line 241, in reverse
    "arguments '%s' not found." % (lookup_view, args, kwargs))
NoReverseMatch: Reverse for 'akoha.akoha.cards.views.mission_factory' with arguments '()' and keyword arguments '{}' not found.
Here's the stack trace i should have seen:
---------------------------------------------------------------------------
             Traceback (most recent call last)

/home/eric/akoha/sandbox/akoha/akoha/backend/akoha/ in ()

/home/eric/akoha/sandbox/akoha/site/lib/python2.5/site-packages/django/core/urlresolvers.py in reverse(viewname, urlconf, args, kwargs, prefix)
    250         prefix = get_script_prefix()
    251     return iri_to_uri(u'%s%s' % (prefix, get_resolver(urlconf).reverse(viewname,
--> 252             *args, **kwargs)))
    253 
    254 def clear_url_caches():

/home/eric/akoha/sandbox/akoha/site/lib/python2.5/site-packages/django/core/urlresolvers.py in reverse(self, lookup_view, *args, **kwargs)
    224         except (ImportError, AttributeError), e:
    225             raise NoReverseMatch("Error importing '%s': %s." % (lookup_view, e))
--> 226         possibilities, pattern = self.reverse_dict.get(lookup_view, [(), ()])
    227         for result, params in possibilities:
    228             if args:

/home/eric/akoha/sandbox/akoha/site/lib/python2.5/site-packages/django/core/urlresolvers.py in _get_reverse_dict(self)
    166                 else:
    167                     bits = normalize(p_pattern)
--> 168                     self._reverse_dict[pattern.callback] = bits, p_pattern
    169                     self._reverse_dict[pattern.name] = bits, p_pattern
    170         return self._reverse_dict

/home/eric/akoha/sandbox/akoha/site/lib/python2.5/site-packages/django/core/urlresolvers.py in _get_callback(self)
    127             return self._callback
    128         try:
--> 129             self._callback = get_callable(self._callback_str)
    130         except ImportError, e:
    131             mod_name, _ = get_mod_func(self._callback_str)

/home/eric/akoha/sandbox/akoha/site/lib/python2.5/site-packages/django/utils/functional.py in wrapper(*args)
    128         if mem_args in cache:
    129             return cache[mem_args]
--> 130         result = func(*args)
    131         cache[mem_args] = result
    132         return result

/home/eric/akoha/sandbox/akoha/site/lib/python2.5/site-packages/django/core/urlresolvers.py in get_callable(lookup_view, can_fail)
     54             mod_name, func_name = get_mod_func(lookup_view)
     55             if func_name != '':
---> 56                 lookup_view = getattr(__import__(mod_name, {}, {}, ['']), func_name)
     57                 if not callable(lookup_view):
     58                     raise AttributeError("'%s.%s' is not a callable." % (mod_name, func_name))

/home/eric/akoha/sandbox/akoha/backend/akoha/feeds/views.py in ()
    306                   context_instance=RequestContext(request))
    307 
--> 308 @group_required('ted_preview')
    309 def activity_map(request, template_name='ActivityMap.html'):
    310     """ Returns activity event for this specifict event id. """

: group_required() takes exactly 2 arguments (1 given)
Had i seen that, and/or would i be a smarter cookie than i really am, i would have understood the issue right away....

Which is of course that i basically can't code, and my decorator with arguments wasn't working. So, for future reference, here's how you really do a decorator that takes arguments, and will preserve the signature of what it's wrapping:

class group_required(object):
    def __init__(self, group):
        self.group = group
    def __call__(self, f):
        def decorator(request, *args, **kwargs):
            u = request.user
            if not u.is_authenticated() or not u.groups.filter(name=self.group):
                raise PermissionDenied
            return f(request, *args, **kwargs)
        return wraps(f)(decorator)

Interestingly, i found out that though django's own decorators do keep signature, their login_required decorator, and all their decorators based on the generic _CheckLogin decorator, won't properly preserve signature. Witness this:

In [2]: reverse('cards.views.mission_factory')
---------------------------------------------------------------------------
Traceback (most recent call last)

/home/eric/akoha/sandbox/akoha/akoha/backend/akoha/ in ()

/home/eric/akoha/sandbox/akoha/site/lib/python2.5/site-packages/django/core/urlresolvers.py in reverse(viewname, urlconf, args, kwargs, prefix)
    250         prefix = get_script_prefix()
    251     return iri_to_uri(u'%s%s' % (prefix, get_resolver(urlconf).reverse(viewname,
--> 252             *args, **kwargs)))
    253 
    254 def clear_url_caches():

/home/eric/akoha/sandbox/akoha/site/lib/python2.5/site-packages/django/core/urlresolvers.py in reverse(self, lookup_view, *args, **kwargs)
    239                 return candidate
    240         raise NoReverseMatch("Reverse for '%s' with arguments '%s' and keyword "
--> 241                 "arguments '%s' not found." % (lookup_view, args, kwargs))
    242 
    243 def resolve(path, urlconf=None):

: Reverse for '' with arguments '()' and keyword arguments '{}' not found.

That bit should really read cards.views.mission_factory, but because that view is decorated with django's login_required, which itself calls the real decorator _CheckLogin but without being, itself, a proper signature-preserving decorator, you lose the signature of your view. _CheckLogin does use wraps() to preserve signature, however... But login_required needs to, as well.

Anyways... very long story short... don't do it. And i hope google finds this information for someone in a similar situation, someday, who's really puzzled about either why his decorator isn't working properly, or why the hell django can't find her view when it's really really really there!

Dig deeper, try reverse() in a shell, you'll find the real stacktrace...

by wiswaud on 4 December 2008
Tags: akoha, django, english, geeky, python, web

Comments

Bertrand Nouvel 8 April 2009 20:48 EDT

Thanks. I just spend 48hours looking at the same thing... wondering why my import did not work... Thanks a lot very instructive.
Django really should solve this urgently.

wiswaud 10 April 2009 09:25 EDT

no problem! if it helped just 1 person it was worth writing the post :)

Yuri 14 August 2009 01:44 EDT

So you haven't written here how to patch django to fix the problem:

To rethrow a new exception, keeping inner stack frames attached, one should use
raise SomeException, "ExceptionName: ", sys.exc_info()[2]
form of raise.

wiswaud 14 August 2009 09:53 EDT

Yuri: thanks, i should indeed have added that tidbit :)

Though, really, catching an exception just so you can rethrow another one just smells bad to me. I'm guessing the reason for doing so was to make it simpler to catch exceptions from that portion of code higher up, but i still don't think that's a valid reason.

What would have to be done, i guess, is check where in the rest of the code it expects that exception, and make it work when the original exceptions get raised instead.

Share this page
| More

follow me on Twitter