Skip to content

Commit

Permalink
Template tags (#3)
Browse files Browse the repository at this point in the history
add template tags and filters, update readme and example_project
  • Loading branch information
pgorecki authored Sep 29, 2020
1 parent 43af84e commit 3314c5a
Show file tree
Hide file tree
Showing 51 changed files with 1,184 additions and 487 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
.vscode/
.pytest_cache/
.pyc
*.sqlite3
*.db
build/
dist/
django_cancan.egg-info/
7 changes: 6 additions & 1 deletion Pipfile
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,15 @@ django-environ = "*"
setuptools = "*"
wheel = "*"
twine = "*"
black = "*"
django-extensions = "*"

[requires]
python_version = "3.7"

[scripts]
build = "python setup.py sdist bdist_wheel"
publish = "twine upload --skip-existing dist/*"
publish = "twine upload --skip-existing dist/*"

[pipenv]
allow_prereleases = true
263 changes: 187 additions & 76 deletions Pipfile.lock

Large diffs are not rendered by default.

141 changes: 125 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,32 +43,43 @@ INSTALLED_APPS = [
]
```

2. Create a function that define user abilites. For example, in `abilities.py`:
2. Create a function that define the access rules for a given user. For example, create `abilities.py` in `myapp` module:

```python
def declare_abilities(user, ability):
def define_access_rules(user, rules):
# Anybody can view published articles
rules.allow('view', Article, published=True)

if not user.is_authenticated:
# Allow anonymous users to view published articles
return ability.can('view', Article, published=True)
return

# Allow logged in user to view his own articles, regardless of the `published` status
rules.allow('view', Article, author=user)

if user.has_perm('article.view_own_article'):
# Allow logged in user to change his articles
return ability.can('change', Article, author=user)
if user.has_perm('article.view_unpublished'):
# You can also check for custom model permissions (i.e. view_unpublished)
rules.allow('view', Article, published=False)


if user.is_superuser:
# Allow superuser change all articles
return ability.can('change', Article)
# Superuser gets unlimited access to all articles
rules.allow('add', Article)
rules.allow('view', Article)
rules.allow('change', Article)
rules.allow('delete', Article)
```

3. Configure `cancan` by adding `CANCAN` section in `settings.py`:
3. In `settings.py` add `CANCAN` section, so that `cancan` library will know where to search for `define_access_rules` function from the previous step:

```python
CANCAN = {
'ABILITIES': 'myapp.abilities.declare_abilities'
'ABILITIES': 'myapp.abilities.define_access_rules'
}
```

Next, add `cancan` middleware after `AuthenticationMiddleware`:
The `define_access_rules` function will be executed automatically per each request by the `cancan` middleware. The middleware will call the function to determine the abilities of a current user.

Let's add `cancan` middleware, just after `AuthenticationMiddleware`:

```python
MIDDLEWARE = [
Expand All @@ -79,18 +90,21 @@ MIDDLEWARE = [
]
```

Adding the middleware adds `request.ability` instance which you can use
to check for: model permissions, object permissions and model querysets.
By adding the middleware you will also get an access to `request.ability` instance which you can use
to:
- check model permissions,
- check object permissions,
- generate model querysets (i.e. in case of `ListView`s

4. Check abilities in views:
4. Check for abilities in views:

```python

class ArticleListView(ListView):
model = Article

def get_queryset():
# this is how you can retrieve all objects a user can access
# this is how you can retrieve all objects that current user can access
qs = self.request.ability.queryset_for('view', Article)
return qs

Expand All @@ -104,6 +118,101 @@ class ArticleDetailView(PermissionRequiredMixin, DetailView):
return self.request.ability.can('view', article)
```

5. Check for abilities in templates

You can also check for abilities in template files, i. e. to show/hide/disable buttons or links.

First you need to add `cancan` processor to `context_processors` in `TEMPLATES` section of `settings.py`:

```python
TEMPLATES = [
{
...,
"OPTIONS": {
"context_processors": [
...,
"cancan.context_processors.abilities",
],
},
},
]
```

This will give you access to `ability` object in a template. You also need add `{% load cancan_tags %}` at the beginning
of the template file.

Next you can check for object permissions:

```
{% if ability|can:"change"|subject:article %}
<a href="{% url 'article_edit' pk=article.id %}">Edit article</a>
{% endif %}
```

or model permissions:

```
{% load cancan_tags %}
...
{% if ability|can:"add"|"myapp.Article" %}
<a href="{% url 'article_new' %}">Create new article</a>
{% endif %}
```

You can also use `can` template tag to create a reusable variable:

```
{% can "add" "core.Project" as can_add_project %}
...
{% if can_add_project %}
...
{% endif %}
```

## Checking for abilities in Django Rest Framework

Let's start by creating a pemission class:

```python
from rest_framework import permissions

def set_aliases_for_drf_actions(ability):
"""
map DRF actions to default Django permissions
"""
ability.access_rules.set_alias("list", "view")
ability.access_rules.set_alias("retrieve", "view")
ability.access_rules.set_alias("create", "add")
ability.access_rules.set_alias("update", "change")
ability.access_rules.set_alias("partial_update", "change")
ability.access_rules.set_alias("destroy", "delete")


class AbilityPermission(permissions.BasePermission):
def has_permission(self, request, view=None):
ability = request.ability
set_aliases_for_drf_actions(ability)
return ability.can(view.action, view.get_queryset().model)

def has_object_permission(self, request, view, obj):
ability = request.ability
set_aliases_for_drf_actions(ability)
return ability.can(view.action, obj)
```

Next, secure the ViewSet with `AbilityPermission` and override `get_queryset` method to list objects based on the access rights.

```python
class ArticleViewset(ModelViewSet):
permission_classes = [AbilityPermission]

def get_queryset(self):
return self.request.ability.queryset_for(self.action, Article).distinct()
```


## Sponsors

<a href="https://ermlab.com/" target="_blank">
Expand Down
93 changes: 36 additions & 57 deletions cancan/ability.py
Original file line number Diff line number Diff line change
@@ -1,54 +1,23 @@
import inspect
from django.apps import apps
from .access_rules import AccessRules, normalize_subject


class Ability:
def __init__(self, user):
self.user = user
self.abilities = []
self.aliases = {}

def can(self, action, model, **kwargs):
if type(model) is str:
model = apps.get_model(model)
self.abilities.append({
'type': 'can',
'action': action,
'model': model,
'conditions': kwargs,
})

def cannot(self, action, model, **kwargs):
if type(model) is str:
model = apps.get_model(model)

self.abilities.append({
'type': 'cannot',
'action': action,
'model': model,
'conditions': kwargs,
})

def set_alias(self, alias, action):
self.aliases[alias] = action

def alias_to_action(self, alias):
return self.aliases.get(alias, alias)


class AbilityValidator:
def __init__(self, ability: Ability):
self.ability = ability
def __init__(self, access_rules: AccessRules):
self.access_rules = access_rules

def validate_model(self, action, model):
can_count = 0
cannot_count = 0
model_abilities = filter(
lambda c: c['model'] == model and c['action'] == action, self.ability.abilities)
lambda c: c["subject"] == model and c["action"] == action,
self.access_rules.rules,
)
for c in model_abilities:
if c['type'] == 'can':
if c["type"] == "can":
can_count += 1
if c['type'] == 'cannot':
if c["type"] == "cannot":
cannot_count += 1

if cannot_count > 0:
Expand All @@ -60,16 +29,19 @@ def validate_model(self, action, model):
def validate_instance(self, action, instance):
model = instance._meta.model
model_abilities = filter(
lambda c: c['model'] == model and c['action'] == action, self.ability.abilities)
lambda c: c["subject"] == model and c["action"] == action,
self.access_rules.rules,
)

query_sets = []
for c in model_abilities:
if c['type'] == 'can':
qs = model.objects.all().filter(pk=instance.id, **c.get('conditions', {}))
if c["type"] == "can":
qs = model.objects.all().filter(
pk=instance.id, **c.get("conditions", {})
)

if c['type'] == 'cannot':
raise NotImplementedError(
'cannot-type rules are not yet implemented')
if c["type"] == "cannot":
raise NotImplementedError("cannot-type rules are not yet implemented")

query_sets.append(qs)

Expand All @@ -82,27 +54,30 @@ def validate_instance(self, action, instance):

return can_query_set.count() > 0

def can(self, action, model_or_instance) -> bool:
action = self.ability.alias_to_action(action)
if inspect.isclass(model_or_instance):
return self.validate_model(action, model_or_instance)
def can(self, action, subject) -> bool:
subject = normalize_subject(subject)
action = self.access_rules.alias_to_action(action)
if inspect.isclass(subject):
return self.validate_model(action, subject)
else:
return self.validate_instance(action, model_or_instance)
return self.validate_instance(action, subject)

def queryset_for(self, action, model):
action = self.ability.alias_to_action(action)
model = normalize_subject(model)
action = self.access_rules.alias_to_action(action)

model_abilities = filter(
lambda c: c['model'] == model and c['action'] == action, self.ability.abilities)
lambda c: c["subject"] == model and c["action"] == action,
self.access_rules.rules,
)

query_sets = []
for c in model_abilities:
if c['type'] == 'can' and 'conditions' in c:
qs = model.objects.all().filter(**c.get('conditions', {}))
if c["type"] == "can" and "conditions" in c:
qs = model.objects.all().filter(**c.get("conditions", {}))

if c['type'] == 'cannot':
raise NotImplementedError(
'cannot-type rules are not yet implemented')
if c["type"] == "cannot":
raise NotImplementedError("cannot-type rules are not yet implemented")

query_sets.append(qs)

Expand All @@ -114,3 +89,7 @@ def queryset_for(self, action, model):
can_query_set |= qs

return can_query_set

def __contains__(self, item):
action, subject = item
return self.can(action, subject)
34 changes: 34 additions & 0 deletions cancan/access_rules.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
from django.apps import apps


def normalize_subject(subject):
if isinstance(subject, str):
try:
app_label, model_name = subject.split(".")
return apps.get_model(app_label, model_name)
except Exception as e:
pass
return subject


class AccessRules:
def __init__(self, user):
self.user = user
self.rules = []
self.action_aliases = {}

def allow(self, action, subject, **kwargs):
rule = {
"type": "can",
"action": action,
"subject": normalize_subject(subject),
"conditions": kwargs,
}
self.rules.append(rule)
return rule

def alias_action(self, action, alias):
self.action_aliases[alias] = action

def alias_to_action(self, alias):
return self.action_aliases.get(alias, alias)
6 changes: 1 addition & 5 deletions cancan/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,4 @@


class CanCanConfig(AppConfig):
name = 'cancan'

def ready(self):
aaa()
pass
name = "cancan"
Loading

0 comments on commit 3314c5a

Please sign in to comment.