2

Some of my views have decorators that restrict access, like so:

@user_passes_test(my_validation_function)
def my_restricted_view(request):
    ...

The thing is, in my templates I would like to hide the links for which the user does not have access, according to the logic in my_validation_function.

I understand that one way of doing this would be defining a custom filter that basically calls my_validation_function (say my_validation_filter), and shows/hides the link accordingly. Something like this:

{% if request | my_validation_filter %}
    <a href="{% url 'my_restricted_view' %}"></a>
{% endif %}

The problem I see here is that I'm linking the validation twice: once in the view, and once in the template. Suppose I have many views, each with different validation logic behind them:

@user_passes_test(my_validation_function)
def my_restricted_view(request):
    ...

@user_passes_test(my_other_validation_function)
def my_other_restricted_view(request):
    ...

This would means that, when I'm writing the templates, I have to be careful to always remember which validation function goes with which view.

Is there a way to define a function or that reverses the URL, and then checks the validations defined in the decorator of the view? I'm thinking something like these:

{% if can_access 'my_restricted_view' %}
    {# this implicitly calls 'my_validation_function' #}
    ...
{% endif %}

{% if can_access 'my_other_restricted_view' %}
    {# this implicitly calls 'my_other_validation_function' #}
    ...
{% endif %}

Basically what I want is to only have to change the validation logic for each view in one place, and not touch my templates as much.

Jaime Sanz
  • 178
  • 7

2 Answers2

1

Your question is very interesting, I have no complete answer, but some tracks.

First of all, it is difficult, maybe impossible to get the decorator from the decorated function, inspect for example, can't do that as I know. But you can move the validation function from the decorator to the decorated function. Replace this:

@user_passes_test(my_validation_function)
def my_restricted_view(request):
    ...

by this:

@user_passes_test
def my_restricted_view(request):
    ...

my_restricted_view.validation_function = my_validation_function

It should be easy to handle this change in the code of the decorator.

Then you can write a custom filter you call as:

{% if request|validation_filter:'my_restricted_view' %}

The code of this filter might look like:

def validation_filter(request, view_name):
   view_func = resolve(reverse(view_name)).func
   validation_func = view_func.validation_function
   return validation_func(request)
albar
  • 3,020
  • 1
  • 14
  • 27
  • Thanks! While I still have not found a way to use the proper validation function, your answer lead me in the right direction. The problem is that I can't reference the `validation_function` defined inside each view, because you can't access function variables this way. However, if I define it like this: `my_restricted_view.validation_function = my_validation_function` it *does* work. I'm still not happy with this solution though, because I don't want to write each function's name twice every time, so I'm trying to define the function's variable inside the decorator. – Jaime Sanz May 12 '17 at 19:11
  • @Jaime Sanz: shame on me, you are right. I update my answer. – albar May 13 '17 at 08:56
  • @Jaime Sanz: maybe you could write a custom template tag that generates the link only if the view is validated. Something you could call `{% link_if_valid 'my_restricted_view' %}`. – albar May 13 '17 at 09:02
  • I managed to modify the decorator so that (besides doing the actual validation) it also defines the attribute `validation_function` in the view (ie, the function being wrapped by the decorator). To do this, I found [this to be helpful](http://stackoverflow.com/a/15794454/7331040). I then implemented the filter as you described, and it's working! If I change the validation function for a view, it hides/sows the links without having to touch the template again. Thanks a lot for the help! – Jaime Sanz May 13 '17 at 20:34
0

The exchange between @albar and @Jamie Sanz put me on the right path. Complete solution below, link to gist (https://gist.github.com/solace/9cfae6cb9c60658857ee73f05d5b715a) as well. Is pretty much a slightly augmented user_passes_test with accompanying template tag.

views.py

from .decorators import user_passes_test
from .utils import perms_check


# pass `test_func` and related test params
@user_passes_test(perms_check, 'a', ...)
def secured(request):
    pass

decorators.py

# Copy of `django.contrib.auth.decorators.user_passes_test`, relevant changes noted.
# CHANGED: `*test_args` for the inclusion of `test_func` arguments
def user_passes_test(test_func, *test_args, login_url=None, redirect_field_name=REDIRECT_FIELD_NAME):
    def decorator(view_func):

        @wraps(view_func)
        def _wrapped_view(request, *args, **kwargs):
            # CHANGED: Pass `*test_args` to the `test_func` 
            if test_func(request.user, *test_args):
                return view_func(request, *args, **kwargs)
            path = request.build_absolute_uri()
            resolved_login_url = resolve_url(login_url or settings.LOGIN_URL)
            # If the login url is the same scheme and net location then just
            # use the path as the "next" url.
            login_scheme, login_netloc = urlparse(resolved_login_url)[:2]
            current_scheme, current_netloc = urlparse(path)[:2]
            if ((not login_scheme or login_scheme == current_scheme) and
                    (not login_netloc or login_netloc == current_netloc)):
                path = request.get_full_path()
            return redirect_to_login(
                path, resolved_login_url, redirect_field_name)
        # ADDED: set the `test_func` and `test_args` on the view function to use later
        _wrapped_view.can_view_validator = (test_func, *test_args)
        return _wrapped_view

    return decorator

permissions.py

from django import template
from django.core.exceptions import PermissionDenied
from django.urls import resolve, reverse


register = template.Library()


# Creates a template tag that looks like this:
# {{ can_view 'my_app:secured' as can_view_secured }}
@register.simple_tag(takes_context=True)
def can_view(context, named):
    # `reverse` the named url to get the url, `resolve` it to get the view function
    # `user_passes_test` returns True/False or redirects, but `test_func` could also
    # raise a `PermissionDenied`. So handle that, too.
    match = resolve(reverse(named))
    try:
        if hasattr(match.func, 'can_view_validator'):
            (test_func, *args) = match.func.can_view_validator
            return test_func(context.request.user, *args)
        else:
            return True
    except PermissionDenied:
        return False

some_template.html

{% load permissions %}

<ul class="menu">
  ...
  {% can_view 'my_app:secured' as can_view_secured %}
  {% if can_view_secured %}
  <li>
    <a href="{% url 'my_app:secured' %}">Secured Link</a>
  </li>
  {% endif %}
  ...
</ul>
Michele
  • 101
  • 1
  • 5