Commit 3f1534ea by Mike Chen

added permission framework.

parent 30f5bdb4
# call some function from permissions so that the post_save hook is imported
from permissions import assign_default_role
......@@ -13,6 +13,7 @@ urlpatterns = patterns('django_comment_client.base.views',
url(r'threads/(?P<thread_id>[\w\-]+)/unvote$', 'undo_vote_for_thread', name='undo_vote_for_thread'),
url(r'threads/(?P<thread_id>[\w\-]+)/follow$', 'follow_thread', name='follow_thread'),
url(r'threads/(?P<thread_id>[\w\-]+)/unfollow$', 'unfollow_thread', name='unfollow_thread'),
url(r'threads/(?P<thread_id>[\w\-]+)/close$', 'openclose_thread', name='openclose_thread'),
url(r'comments/(?P<comment_id>[\w\-]+)/update$', 'update_comment', name='update_comment'),
url(r'comments/(?P<comment_id>[\w\-]+)/endorse$', 'endorse_comment', name='endorse_comment'),
......
......@@ -18,6 +18,64 @@ from django.conf import settings
from mitxmako.shortcuts import render_to_response, render_to_string
from django_comment_client.utils import JsonResponse, JsonError, extract
from django_comment_client.permissions import has_permission, has_permission
import functools
#
def permitted(*per):
"""
Accepts a list of permissions and proceed if any of the permission is valid.
Note that @permitted("can_view", "can_edit") will proceed if the user has either
"can_view" or "can_edit" permission. To use AND operator in between, wrap them in
a list:
@permitted(["can_view", "can_edit"])
Special conditions can be used like permissions, e.g.
@permitted(["can_vote", "open"]) # where open is True if not content['closed']
"""
def decorator(fn):
@functools.wraps(fn)
def wrapper(request, *args, **kwargs):
permissions = filter(lambda x: len(x), list(per))
user = request.user
import pdb; pdb.set_trace()
def fetch_content():
if "thread_id" in kwargs:
content = comment_client.get_thread(kwargs["thread_id"])
elif "comment_id" in kwargs:
content = comment_client.get_comment(kwargs["comment_id"])
else:
logging.warning("missing thread_id or comment_id")
return None
return content
def test_permission(user, permission, operator="or"):
if isinstance(permission, basestring):
if permission == "":
return True
elif permission == "author":
return fetch_content()["user_id"] == request.user.id
elif permission == "open":
return not fetch_content()["closed"]
return has_permission(user, permission)
elif isinstance(permission, list) and operator in ["and", "or"]:
results = [test_permission(user, x, operator="and") for x in permission]
if operator == "or":
return True in results
elif operator == "and":
return not False in results
if test_permission(user, permissions, operator="or"):
return fn(request, *args, **kwargs)
else:
return JsonError("unauthorized")
return wrapper
return decorator
def thread_author_only(fn):
def verified_fn(request, *args, **kwargs):
thread_id = kwargs.get('thread_id', False)
......@@ -48,6 +106,7 @@ def instructor_only(fn):
@require_POST
@login_required
@permitted("create_thread")
def create_thread(request, course_id, commentable_id):
attributes = extract(request.POST, ['body', 'title', 'tags'])
attributes['user_id'] = request.user.id
......@@ -72,7 +131,7 @@ def create_thread(request, course_id, commentable_id):
@require_POST
@login_required
@thread_author_only
@permitted("edit_content", ["update_thread", "open", "author"])
def update_thread(request, course_id, thread_id):
attributes = extract(request.POST, ['body', 'title', 'tags'])
response = comment_client.update_thread(thread_id, attributes)
......@@ -112,6 +171,7 @@ def _create_comment(request, course_id, _response_from_attributes):
@require_POST
@login_required
@permitted(["create_comment", "open"])
def create_comment(request, course_id, thread_id):
def _response_from_attributes(attributes):
return comment_client.create_comment(thread_id, attributes)
......@@ -119,14 +179,14 @@ def create_comment(request, course_id, thread_id):
@require_POST
@login_required
@thread_author_only
@permitted("delete_thread")
def delete_thread(request, course_id, thread_id):
response = comment_client.delete_thread(thread_id)
return JsonResponse(response)
@require_POST
@login_required
@comment_author_only
@permitted("update_comment", ["update_comment", "open", "author"])
def update_comment(request, course_id, comment_id):
attributes = extract(request.POST, ['body'])
response = comment_client.update_comment(comment_id, attributes)
......@@ -145,7 +205,7 @@ def update_comment(request, course_id, comment_id):
@require_POST
@login_required
@instructor_only
@permitted("endorse_comment")
def endorse_comment(request, course_id, comment_id):
attributes = extract(request.POST, ['endorsed'])
response = comment_client.update_comment(comment_id, attributes)
......@@ -153,6 +213,15 @@ def endorse_comment(request, course_id, comment_id):
@require_POST
@login_required
@permitted("openclose_thread")
def openclose_thread(request, course_id, thread_id):
attributes = extract(request.POST, ['closed'])
response = comment_client.update_thread(thread_id, attributes)
return JsonResponse(response)
@require_POST
@login_required
@permitted(["create_sub_comment", "open"])
def create_sub_comment(request, course_id, comment_id):
def _response_from_attributes(attributes):
return comment_client.create_sub_comment(comment_id, attributes)
......@@ -160,13 +229,14 @@ def create_sub_comment(request, course_id, comment_id):
@require_POST
@login_required
@comment_author_only
@permitted("delete_comment")
def delete_comment(request, course_id, comment_id):
response = comment_client.delete_comment(comment_id)
return JsonResponse(response)
@require_POST
@login_required
@permitted(["vote", "open"])
def vote_for_comment(request, course_id, comment_id, value):
user_id = request.user.id
response = comment_client.vote_for_comment(comment_id, user_id, value)
......@@ -174,6 +244,7 @@ def vote_for_comment(request, course_id, comment_id, value):
@require_POST
@login_required
@permitted(["unvote", "open"])
def undo_vote_for_comment(request, course_id, comment_id):
user_id = request.user.id
response = comment_client.undo_vote_for_comment(comment_id, user_id)
......@@ -181,6 +252,7 @@ def undo_vote_for_comment(request, course_id, comment_id):
@require_POST
@login_required
@permitted(["vote", "open"])
def vote_for_thread(request, course_id, thread_id, value):
user_id = request.user.id
response = comment_client.vote_for_thread(thread_id, user_id, value)
......@@ -188,6 +260,7 @@ def vote_for_thread(request, course_id, thread_id, value):
@require_POST
@login_required
@permitted(["unvote", "open"])
def undo_vote_for_thread(request, course_id, thread_id):
user_id = request.user.id
response = comment_client.undo_vote_for_thread(thread_id, user_id)
......@@ -195,6 +268,7 @@ def undo_vote_for_thread(request, course_id, thread_id):
@require_POST
@login_required
@permitted("follow_thread")
def follow_thread(request, course_id, thread_id):
user_id = request.user.id
response = comment_client.subscribe_thread(user_id, thread_id)
......@@ -202,6 +276,7 @@ def follow_thread(request, course_id, thread_id):
@require_POST
@login_required
@permitted("follow_commentable")
def follow_commentable(request, course_id, commentable_id):
user_id = request.user.id
response = comment_client.subscribe_commentable(user_id, commentable_id)
......@@ -209,6 +284,7 @@ def follow_commentable(request, course_id, commentable_id):
@require_POST
@login_required
@permitted("follow_user")
def follow_user(request, course_id, followed_user_id):
user_id = request.user.id
response = comment_client.follow(user_id, followed_user_id)
......@@ -216,6 +292,7 @@ def follow_user(request, course_id, followed_user_id):
@require_POST
@login_required
@permitted("unfollow_thread")
def unfollow_thread(request, course_id, thread_id):
user_id = request.user.id
response = comment_client.unsubscribe_thread(user_id, thread_id)
......@@ -223,6 +300,7 @@ def unfollow_thread(request, course_id, thread_id):
@require_POST
@login_required
@permitted("unfollow_commentable")
def unfollow_commentable(request, course_id, commentable_id):
user_id = request.user.id
response = comment_client.unsubscribe_commentable(user_id, commentable_id)
......@@ -230,6 +308,7 @@ def unfollow_commentable(request, course_id, commentable_id):
@require_POST
@login_required
@permitted("unfollow_user")
def unfollow_user(request, course_id, followed_user_id):
user_id = request.user.id
response = comment_client.unfollow(user_id, followed_user_id)
......
# -*- coding: utf-8 -*-
import datetime
from south.db import db
from south.v2 import SchemaMigration
from django.db import models
class Migration(SchemaMigration):
def forwards(self, orm):
# Adding model 'Role'
db.create_table('django_comment_client_role', (
('name', self.gf('django.db.models.fields.CharField')(max_length=30, primary_key=True)),
))
db.send_create_signal('django_comment_client', ['Role'])
# Adding M2M table for field users on 'Role'
db.create_table('django_comment_client_role_users', (
('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True)),
('role', models.ForeignKey(orm['django_comment_client.role'], null=False)),
('user', models.ForeignKey(orm['auth.user'], null=False))
))
db.create_unique('django_comment_client_role_users', ['role_id', 'user_id'])
# Adding model 'Permission'
db.create_table('django_comment_client_permission', (
('name', self.gf('django.db.models.fields.CharField')(max_length=30, primary_key=True)),
))
db.send_create_signal('django_comment_client', ['Permission'])
# Adding M2M table for field users on 'Permission'
db.create_table('django_comment_client_permission_users', (
('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True)),
('permission', models.ForeignKey(orm['django_comment_client.permission'], null=False)),
('user', models.ForeignKey(orm['auth.user'], null=False))
))
db.create_unique('django_comment_client_permission_users', ['permission_id', 'user_id'])
# Adding M2M table for field roles on 'Permission'
db.create_table('django_comment_client_permission_roles', (
('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True)),
('permission', models.ForeignKey(orm['django_comment_client.permission'], null=False)),
('role', models.ForeignKey(orm['django_comment_client.role'], null=False))
))
db.create_unique('django_comment_client_permission_roles', ['permission_id', 'role_id'])
def backwards(self, orm):
# Deleting model 'Role'
db.delete_table('django_comment_client_role')
# Removing M2M table for field users on 'Role'
db.delete_table('django_comment_client_role_users')
# Deleting model 'Permission'
db.delete_table('django_comment_client_permission')
# Removing M2M table for field users on 'Permission'
db.delete_table('django_comment_client_permission_users')
# Removing M2M table for field roles on 'Permission'
db.delete_table('django_comment_client_permission_roles')
models = {
'auth.group': {
'Meta': {'object_name': 'Group'},
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
},
'auth.permission': {
'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
},
'auth.user': {
'Meta': {'object_name': 'User'},
'about': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
'avatar_type': ('django.db.models.fields.CharField', [], {'default': "'n'", 'max_length': '1'}),
'bronze': ('django.db.models.fields.SmallIntegerField', [], {'default': '0'}),
'consecutive_days_visit_count': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
'country': ('django_countries.fields.CountryField', [], {'max_length': '2', 'blank': 'True'}),
'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'date_of_birth': ('django.db.models.fields.DateField', [], {'null': 'True', 'blank': 'True'}),
'display_tag_filter_strategy': ('django.db.models.fields.SmallIntegerField', [], {'default': '0'}),
'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
'email_isvalid': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'email_key': ('django.db.models.fields.CharField', [], {'max_length': '32', 'null': 'True'}),
'email_tag_filter_strategy': ('django.db.models.fields.SmallIntegerField', [], {'default': '1'}),
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'gold': ('django.db.models.fields.SmallIntegerField', [], {'default': '0'}),
'gravatar': ('django.db.models.fields.CharField', [], {'max_length': '32'}),
'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'ignored_tags': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
'interesting_tags': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
'last_seen': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
'location': ('django.db.models.fields.CharField', [], {'max_length': '100', 'blank': 'True'}),
'new_response_count': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
'questions_per_page': ('django.db.models.fields.SmallIntegerField', [], {'default': '10'}),
'real_name': ('django.db.models.fields.CharField', [], {'max_length': '100', 'blank': 'True'}),
'reputation': ('django.db.models.fields.PositiveIntegerField', [], {'default': '1'}),
'seen_response_count': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
'show_country': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
'silver': ('django.db.models.fields.SmallIntegerField', [], {'default': '0'}),
'status': ('django.db.models.fields.CharField', [], {'default': "'w'", 'max_length': '2'}),
'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}),
'website': ('django.db.models.fields.URLField', [], {'max_length': '200', 'blank': 'True'})
},
'contenttypes.contenttype': {
'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
},
'django_comment_client.permission': {
'Meta': {'object_name': 'Permission'},
'name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'primary_key': 'True'}),
'roles': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'permissions'", 'symmetrical': 'False', 'to': "orm['django_comment_client.Role']"}),
'users': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'permissions'", 'symmetrical': 'False', 'to': "orm['auth.User']"})
},
'django_comment_client.role': {
'Meta': {'object_name': 'Role'},
'name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'primary_key': 'True'}),
'users': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'roles'", 'symmetrical': 'False', 'to': "orm['auth.User']"})
}
}
complete_apps = ['django_comment_client']
\ No newline at end of file
from django.db import models
from django.contrib.auth.models import User
class Role(models.Model):
name = models.CharField(max_length=30, null=False, blank=False, primary_key=True)
users = models.ManyToManyField(User, related_name="roles")
def __unicode__(self):
return self.name
@staticmethod
def register(name):
return Role.objects.get_or_create(name=name)[0]
def register_permissions(self, permissions):
for p in permissions:
if not self.permissions.filter(name=p):
self.permissions.add(Permission.register(p))
def inherit_permissions(self, role):
self.register_permissions(map(lambda p: p.name, role.permissions.all()))
class Permission(models.Model):
name = models.CharField(max_length=30, null=False, blank=False, primary_key=True)
users = models.ManyToManyField(User, related_name="permissions")
roles = models.ManyToManyField(Role, related_name="permissions")
def __unicode__(self):
return self.name
@staticmethod
def register(name):
return Permission.objects.get_or_create(name=name)[0]
from .models import Role, Permission
from django.contrib.auth.models import User
from django.db.models.signals import post_save
from django.dispatch import receiver
import logging
def has_permission(user, p):
if not Permission.objects.filter(name=p).exists():
logging.warning("Permission %s was not registered. " % p)
if Permission.objects.filter(users=user, name=p).exists():
return True
if Permission.objects.filter(roles__in=user.roles.all(), name=p).exists():
return True
return False
def has_permissions(user, *args):
for p in args:
if not has_permission(user, p):
return False
return True
def add_permission(instance, p):
permission = Permission.register(name=p)
if isinstance(instance, User) or isinstance(isinstance, Role):
instance.permissions.add(permission)
else:
raise TypeError("Permission can only be added to a role or user")
@receiver(post_save, sender=User)
def assign_default_role(sender, instance, **kwargs):
# if kwargs.get("created", True):
role = moderator_role if instance.is_staff else student_role
logging.info("assign_default_role: adding %s as %s" % (instance, role))
instance.roles.add(role)
moderator_role = Role.register("Moderator")
student_role = Role.register("Student")
moderator_role.register_permissions(["edit_content", "delete_thread", "openclose_thread",
"update_thread", "endorse_comment", "delete_comment"])
student_role.register_permissions(["vote", "update_thread", "follow_thread", "unfollow_thread",
"update_comment", "create_sub_comment", "unvote" , "create_thread",
"follow_commentable", "unfollow_commentable", "create_comment", ])
moderator_role.inherit_permissions(student_role)
\ No newline at end of file
from django.contrib.auth.models import User
from django.utils import unittest
import string
import random
from .permissions import student_role, moderator_role, add_permission, has_permission
from .models import Role, Permission
class PermissionsTestCase(unittest.TestCase):
def random_str(self, length=15, chars=string.ascii_uppercase + string.digits):
return ''.join(random.choice(chars) for x in range(length))
def setUp(self):
self.student = User.objects.create(username=self.random_str(),
password="123456", email="john@yahoo.com")
self.moderator = User.objects.create(username=self.random_str(),
password="123456", email="staff@edx.org")
self.moderator.is_staff = True
self.moderator.save()
def tearDown(self):
self.student.delete()
self.moderator.delete()
def testDefaultRoles(self):
self.assertTrue(student_role in self.student.roles.all())
self.assertTrue(moderator_role in self.moderator.roles.all())
def testPermission(self):
name = self.random_str()
Permission.register(name)
add_permission(moderator_role, name)
self.assertTrue(has_permission(self.moderator, name))
add_permission(self.student, name)
self.assertTrue(has_permission(self.student, name))
\ No newline at end of file
......@@ -115,7 +115,7 @@ class JsonError(HttpResponse):
indent=2,
ensure_ascii=False)
super(JsonError, self).__init__(content,
mimetype='application/json; charset=utf8')
mimetype='application/json; charset=utf8', status=500)
class HtmlResponse(HttpResponse):
def __init__(self, html=''):
......
......@@ -195,6 +195,33 @@ initializeFollowThread = (thread) ->
else
$(content).removeClass("endorsed")
handleOpenClose = (elem, text) ->
url = Discussion.urlFor('openclose_thread', id)
closed = undefined
if text.match(/Close/)
closed = true
else if text.match(/[Oo]pen/)
closed = false
else
return console.log "Unexpected text " + text + "for open/close thread."
Discussion.safeAjax
$elem: $(elem)
url: url
type: "POST"
dataType: "json"
data: {closed: closed}
success: (response, textStatus) =>
if textStatus == "success"
if closed
$(content).addClass("closed")
$(elem).text "Re-open Thread"
else
$(content).removeClass("closed")
$(elem).text "Close Thread"
error: (response, textStatus, e) ->
console.log e
handleHideSingleThread = (elem) ->
$threadTitle = $local(".thread-title")
$showComments = $local(".discussion-show-comments")
......@@ -271,6 +298,9 @@ initializeFollowThread = (thread) ->
"click .discussion-endorse": ->
handleEndorse(this, $(this).is(":checked"))
"click .discussion-openclose": ->
handleOpenClose(this, $(this).text())
"click .discussion-edit": ->
if $content.hasClass("thread")
handleEditThread(this)
......
......@@ -29,6 +29,7 @@ wmdEditors = {}
undo_vote_for_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/unvote"
follow_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/follow"
unfollow_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/unfollow"
openclose_thread : "/courses/#{$$course_id}/discussion/threads/#{param}/close"
update_comment : "/courses/#{$$course_id}/discussion/comments/#{param}/update"
endorse_comment : "/courses/#{$$course_id}/discussion/comments/#{param}/endorse"
create_sub_comment : "/courses/#{$$course_id}/discussion/comments/#{param}/reply"
......
......@@ -86,6 +86,13 @@
% endif
<label class="discussion-link" for="discussion-endorse-${content['id']}">Endorsed</label>
% endif
% if type == "thread" and request.user.is_staff:
% if content['closed']:
<a class="discussion-openclose" id="discussion-openclose-${content['id']}" href="javascript:void(0);">Re-open thread</a>
% else:
<a class="discussion-openclose" id="discussion-openclose-${content['id']}" href="javascript:void(0);">Close thread</a>
% endif
% endif
</div>
</%def>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment