Skip to content

Commit

Permalink
Merge branch 'acl_per_page'. needs tests and docs
Browse files Browse the repository at this point in the history
  • Loading branch information
mgaitan committed Sep 22, 2014
2 parents 610d7ff + 85eeaa7 commit af74ed8
Show file tree
Hide file tree
Showing 29 changed files with 747 additions and 13 deletions.
1 change: 1 addition & 0 deletions README.rst
Expand Up @@ -32,6 +32,7 @@ At a glance, Waliki has:
- Version control for your content using Git
- Extensible architecture with plugins
- Markdown, reStructuredText or textile markups. Easy to add more.
- A simple ACL system
- UI based on Twitter's Bootstrap

How to start
Expand Down
2 changes: 1 addition & 1 deletion requirements-test.txt
@@ -1,6 +1,6 @@
django>=1.5.1
coverage
coveralls
factory-boy==2.4.1
mock>=1.0.1
nose>=1.3.0
django-nose>=1.2
Expand Down
3 changes: 2 additions & 1 deletion runtests.py
Expand Up @@ -23,7 +23,8 @@
"waliki",
],
SITE_ID=1,
NOSE_ARGS=['-s'],
NOSE_ARGS=['-s', '--nologcapture', '--nocapture',
'--with-id', '--logging-clear-handlers'],
WALIKI_DATA_DIR=WALIKI_DATA_DIR,
)

Expand Down
84 changes: 84 additions & 0 deletions tests/factories.py
@@ -0,0 +1,84 @@
import factory
from django.contrib.auth.models import User, Group, Permission
from waliki.models import ACLRule


class UserFactory(factory.django.DjangoModelFactory):
username = factory.Sequence(lambda n: u'user{0}'.format(n))
password = 'pass'
email = factory.LazyAttribute(lambda o: '%s@example.org' % o.username)

class Meta:
model = User

@factory.post_generation
def groups(self, create, extracted, **kwargs):
if not create:
# Simple build, do nothing.
return

if extracted:
# A list of groups were passed in, use them
for group in extracted:
self.groups.add(group)


class GroupFactory(factory.django.DjangoModelFactory):
class Meta:
model = Group

name = factory.Sequence(lambda n: "Group #%s" % n)

@factory.post_generation
def users(self, create, extracted, **kwargs):
if not create:
# Simple build, do nothing.
return

if extracted:
# A list of groups were passed in, use them
for user in extracted:
self.user_set.add(user)


class ACLRuleFactory(factory.django.DjangoModelFactory):
class Meta:
model = ACLRule

name = factory.Sequence(lambda n: u'Rule {0}'.format(n))
slug = factory.Sequence(lambda n: u'page{0}'.format(n))

@factory.post_generation
def permissions(self, create, extracted, **kwargs):
if not create:
# Simple build, do nothing.
return

if extracted:
# A list of groups were passed in, use them
for perm in extracted:
if not isinstance(perm, Permission):
perm = Permission.objects.get(content_type__app_label='waliki', codename=perm)
self.permissions.add(perm)

@factory.post_generation
def users(self, create, extracted, **kwargs):
if not create:
# Simple build, do nothing.
return

if extracted:
# A list of groups were passed in, use them
for user in extracted:
self.users.add(user)

@factory.post_generation
def groups(self, create, extracted, **kwargs):
if not create:
# Simple build, do nothing.
return

if extracted:
# A list of groups were passed in, use them
for group in extracted:
self.groups.add(group)
40 changes: 40 additions & 0 deletions tests/test_acl.py
@@ -0,0 +1,40 @@
from .factories import UserFactory, GroupFactory, ACLRuleFactory
from waliki.models import ACLRule
from django.test import TestCase


class TestGetUsersRules(TestCase):

def test_simple_user(self):
user = UserFactory()
ACLRuleFactory(slug='page', permissions=['view_page'], users=[user])
users = ACLRule.get_users_for('view_page', 'page')
self.assertEqual(set(users), {user})

def test_simple_group(self):
group_users = [UserFactory(), UserFactory()]
group = GroupFactory(users=group_users)
ACLRuleFactory(slug='page', permissions=['view_page'], groups=[group])
users = ACLRule.get_users_for('view_page', 'page')
self.assertEqual(set(users), set(group_users))

def test_mixing_group_and_users(self):
user = UserFactory()
group1_users = [UserFactory(), UserFactory()]
group2_users = [UserFactory(), UserFactory()]
group1 = GroupFactory(users=group1_users)
group2 = GroupFactory(users=group2_users)
ACLRuleFactory(slug='page', permissions=['view_page'],
groups=[group1, group2], users=[user])
users = ACLRule.get_users_for('view_page', 'page')
self.assertEqual(set(users), set(group1_users + group2_users + [user]))

def test_is_distinct(self):
user = UserFactory()
group1_users = [user]
group1 = GroupFactory(users=group1_users)
ACLRuleFactory(slug='page', permissions=['view_page'],
groups=[group1], users=[user])
users = ACLRule.get_users_for('view_page', 'page')
self.assertEqual(users.count(), 1)
self.assertEqual(set(users), set(group1_users))
7 changes: 7 additions & 0 deletions waliki/admin.py
@@ -0,0 +1,7 @@
from django.contrib import admin
from .models import Page, ACLRule

# Register your models here.
admin.site.register(Page)
admin.site.register(ACLRule)

68 changes: 68 additions & 0 deletions waliki/decorators.py
@@ -0,0 +1,68 @@
from functools import wraps
from django.conf import settings
from django.shortcuts import render
from django.core.exceptions import PermissionDenied
from django.utils.decorators import available_attrs
from django.utils.encoding import force_str
from django.utils.six.moves.urllib.parse import urlparse
from django.contrib.auth import REDIRECT_FIELD_NAME
from django.shortcuts import resolve_url
from .models import ACLRule
from .settings import WALIKI_ANONYMOUS_USER_PERMISSIONS, WALIKI_LOGGED_USER_PERMISSIONS


def page_permission(perm, login_url=None, raise_exception=False, redirect_field_name=REDIRECT_FIELD_NAME):
def decorator(view_func):
@wraps(view_func, assigned=available_attrs(view_func))
def _wrapped_view(request, *args, **kwargs):

def check_perms(user):
allowed_users = ACLRule.get_users_for(perm, kwargs['slug'])
if allowed_users:
return user in allowed_users

if perm in WALIKI_ANONYMOUS_USER_PERMISSIONS:
return True

if user.is_authenticated() and perm in WALIKI_LOGGED_USER_PERMISSIONS:
return True

if not isinstance(perm, (list, tuple)):
perms = (perm, )
else:
perms = perm
# First check if the user has the permission (even anon users)
if user.has_perms(perms):
return True
# In case the 403 handler should be called raise the exception
if raise_exception:
raise PermissionDenied
# As the last resort, show the login form
return False

if check_perms(request.user):
return view_func(request, *args, **kwargs)

if request.user.is_authenticated():
if WALIKI_RENDER_403:
return render(request, 'waliki/403.html', kwargs, status=403)
else:
raise PermissionDenied

path = request.build_absolute_uri()
# urlparse chokes on lazy objects in Python 3, force to str
resolved_login_url = force_str(
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()
from django.contrib.auth.views import redirect_to_login
return redirect_to_login(
path, resolved_login_url, redirect_field_name)
return _wrapped_view

return decorator
2 changes: 1 addition & 1 deletion waliki/git/__init__.py
Expand Up @@ -23,7 +23,7 @@ def __init__(self):
def commit(self, path, message='', author=None):
kwargs = {}
if isinstance(author, User) and author.is_authenticated():
kwargs['author'] = "% <%s>" % (author.get_full_name() or author.username)
kwargs['author'] = u"%s <%s>" % (author.get_full_name() or author.username, author.email)
elif isinstance(author, six.string_types):
kwargs['author'] = author
try:
Expand Down
43 changes: 38 additions & 5 deletions waliki/models.py
@@ -1,21 +1,31 @@
# -*- coding: utf-8 -*-
import os.path
from django.db import models
from django.db.models import Q
from django.conf import settings
from django.core.urlresolvers import reverse
from django.utils.translation import ugettext_lazy as _
from waliki import _markups, settings
from django.contrib.auth.models import Permission, Group
from django.contrib.auth import get_user_model

from . import _markups
from .settings import WALIKI_DEFAULT_MARKUP, WALIKI_MARKUPS_SETTINGS, WALIKI_DATA_DIR


class Page(models.Model):
MARKUP_CHOICES = [(m.name, m.name) for m in _markups.get_all_markups()]
title = models.CharField(verbose_name=_('Title'), max_length=200)
slug = models.CharField(max_length=200, unique=True)
path = models.CharField(max_length=200, unique=True)
markup = models.CharField(verbose_name=_('Markup'), max_length=20, choices=MARKUP_CHOICES, default=settings.WALIKI_DEFAULT_MARKUP)
markup = models.CharField(verbose_name=_('Markup'), max_length=20,
choices=MARKUP_CHOICES, default=WALIKI_DEFAULT_MARKUP)

class Meta:
verbose_name = _('Page')
verbose_name_plural = _('Pages')
permissions = (
('view_page', 'Can view page'),
)

def __str__(self):
return self.__unicode__()
Expand Down Expand Up @@ -44,7 +54,7 @@ def raw(self):

@raw.setter
def raw(self, value):
filename = os.path.join(settings.WALIKI_DATA_DIR, self.path)
filename = os.path.join(WALIKI_DATA_DIR, self.path)
try:
os.makedirs(os.path.dirname(filename))
except OSError:
Expand All @@ -54,11 +64,11 @@ def raw(self, value):

@property
def abspath(self):
return os.path.abspath(os.path.join(settings.WALIKI_DATA_DIR, self.path))
return os.path.abspath(os.path.join(WALIKI_DATA_DIR, self.path))

@staticmethod
def get_markup_instance(markup):
markup_settings = settings.WALIKI_MARKUPS_SETTINGS.get(markup, None)
markup_settings = WALIKI_MARKUPS_SETTINGS.get(markup, None)
markup_class = _markups.find_markup_class_by_name(markup)
return markup_class(**markup_settings)

Expand Down Expand Up @@ -86,3 +96,26 @@ def stylesheet(self):
@property
def javascript(self):
return self._get_part('get_javascript')


class ACLRule(models.Model):
name = models.CharField(verbose_name=_('Name'), max_length=200, unique=True)
slug = models.CharField(max_length=200)
as_namespace = models.BooleanField(verbose_name=_('As namespace'), default=False)
permissions = models.ManyToManyField(Permission, limit_choices_to={'content_type__app_label': 'waliki'})
users = models.ManyToManyField(settings.AUTH_USER_MODEL, blank=True)
groups = models.ManyToManyField(Group, blank=True)

def __unicode__(self):
return u'Rule: ' + self.name + ' for /' + self.slug

def __str__(self):
return self.__unicode__()

@classmethod
def get_users_for(cls, perm, slug):
# TODO check parent namespace for an slug
User = get_user_model()
lookup = Q(aclrule__permissions__codename=perm, aclrule__slug=slug)
lookup |= Q(groups__aclrule__permissions__codename=perm, groups__aclrule__slug=slug)
return User.objects.filter(lookup).distinct()
9 changes: 9 additions & 0 deletions waliki/settings.py
Expand Up @@ -67,3 +67,12 @@ def _get_markup_settings(user_settings):

WALIKI_CODEMIRROR_SETTINGS = getattr(settings, 'WALIKI_CODEMIRROR_SETTINGS',
{'lineNumbers': False, 'theme': 'monokai', 'autofocus': True})

# ('view_page', 'add_page', 'change_page', 'delete_page')
WALIKI_ANONYMOUS_USER_PERMISSIONS = getattr(settings, 'WALIKI_ANONYMOUS_USER_PERMISSIONS',
('view_page',))

WALIKI_LOGGED_USER_PERMISSIONS = getattr(settings, 'WALIKI_LOGGED_USER_PERMISSIONS',
('add_page', 'change_page'))

WALIKI_RENDER_403 = getattr(settings, 'WALIKI_RENDER_403', True)
6 changes: 6 additions & 0 deletions waliki/templates/waliki/403.html
@@ -0,0 +1,6 @@
{% extends "site_base.html" %}
{% load i18n %}

{% block content %}
<p>{% trans "You aren't authorized to see this page." %}</p>
{% endblock content %}
4 changes: 2 additions & 2 deletions waliki/templates/waliki/detail.html
Expand Up @@ -6,7 +6,7 @@
<div class="pull-right">
{% block actions %}
<div class="btn-group">
<a href="{% url 'waliki_edit' slug=page.slug %}" class="btn btn-default">{% trans "Edit" %}</a>
<a href="{% url 'waliki_edit' slug=page.slug|default:slug %}" class="btn btn-default">{% trans "Edit" %}</a>
<button class="btn dropdown-toggle btn-default" data-toggle="dropdown">
<span class="caret"></span>
</button>
Expand All @@ -29,7 +29,7 @@
{{ page.body|safe }}
{% else %}
<p>{% trans "This page doesn't exist yet." %}</p>
<p><a href="{% url 'waliki_edit' slug=page.slug %}" class="btn btn-success">{% trans "Create it" %}</a></p>
<p><a href="{% url 'waliki_edit' slug=page.slug|default:slug %}" class="btn btn-success">{% trans "Create it" %}</a></p>
{% endif %}

{% if page.footer %}
Expand Down
2 changes: 1 addition & 1 deletion waliki/templates/waliki/edit.html
Expand Up @@ -53,7 +53,7 @@
<a class="btn btn-default" href="#preview" id="previewbtn">{% trans "Preview" %}</a>
</div>
<div class="pull-right">
<a class="btn btn-default" href="{% url 'waliki_detail' page.slug %}">{% trans "Cancel" %}</a>
<a class="btn btn-default" href="{% url 'waliki_detail' page.slug|default:slug %}">{% trans "Cancel" %}</a>
<button class="btn btn-success" type="submit">{% trans "Save" %}</button>
</div>
</div>
Expand Down
3 changes: 3 additions & 0 deletions waliki/views.py
Expand Up @@ -5,13 +5,15 @@
from .forms import PageForm
from .signals import page_saved
from ._markups import get_all_markups
from .decorators import page_permission
from . import settings


def home(request):
return detail(request, slug=settings.WALIKI_INDEX_SLUG)


@page_permission('view_page')
def detail(request, slug):
slug = slug.strip('/')
try:
Expand All @@ -21,6 +23,7 @@ def detail(request, slug):
return render(request, 'waliki/detail.html', {'page': page, 'slug': slug})


@page_permission('change_page')
def edit(request, slug):
slug = slug.strip('/')
page, _ = Page.objects.get_or_create(slug=slug)
Expand Down
1 change: 1 addition & 0 deletions waliki_project/requirements.txt
@@ -0,0 +1 @@
django-allauth

0 comments on commit af74ed8

Please sign in to comment.