diff --git a/lms/djangoapps/django_comment_client/__init__.py b/lms/djangoapps/django_comment_client/__init__.py index e69de29bb2..61f59d6f9b 100644 --- a/lms/djangoapps/django_comment_client/__init__.py +++ b/lms/djangoapps/django_comment_client/__init__.py @@ -0,0 +1,2 @@ +# call some function from permissions so that the post_save hook is imported +from permissions import assign_default_role diff --git a/lms/djangoapps/django_comment_client/base/urls.py b/lms/djangoapps/django_comment_client/base/urls.py index 0c3fc24513..e27e041c4b 100644 --- a/lms/djangoapps/django_comment_client/base/urls.py +++ b/lms/djangoapps/django_comment_client/base/urls.py @@ -13,6 +13,7 @@ urlpatterns = patterns('django_comment_client.base.views', url(r'threads/(?P[\w\-]+)/unvote$', 'undo_vote_for_thread', name='undo_vote_for_thread'), url(r'threads/(?P[\w\-]+)/follow$', 'follow_thread', name='follow_thread'), url(r'threads/(?P[\w\-]+)/unfollow$', 'unfollow_thread', name='unfollow_thread'), + url(r'threads/(?P[\w\-]+)/close$', 'openclose_thread', name='openclose_thread'), url(r'comments/(?P[\w\-]+)/update$', 'update_comment', name='update_comment'), url(r'comments/(?P[\w\-]+)/endorse$', 'endorse_comment', name='endorse_comment'), diff --git a/lms/djangoapps/django_comment_client/base/views.py b/lms/djangoapps/django_comment_client/base/views.py index 4c78fd94a1..9173c205e0 100644 --- a/lms/djangoapps/django_comment_client/base/views.py +++ b/lms/djangoapps/django_comment_client/base/views.py @@ -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) diff --git a/lms/djangoapps/django_comment_client/migrations/0001_initial.py b/lms/djangoapps/django_comment_client/migrations/0001_initial.py new file mode 100644 index 0000000000..826cbdae35 --- /dev/null +++ b/lms/djangoapps/django_comment_client/migrations/0001_initial.py @@ -0,0 +1,140 @@ +# -*- 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 diff --git a/lms/djangoapps/django_comment_client/migrations/__init__.py b/lms/djangoapps/django_comment_client/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/django_comment_client/models.py b/lms/djangoapps/django_comment_client/models.py new file mode 100644 index 0000000000..22794677fa --- /dev/null +++ b/lms/djangoapps/django_comment_client/models.py @@ -0,0 +1,36 @@ +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] + diff --git a/lms/djangoapps/django_comment_client/permissions.py b/lms/djangoapps/django_comment_client/permissions.py new file mode 100644 index 0000000000..3bd69969cb --- /dev/null +++ b/lms/djangoapps/django_comment_client/permissions.py @@ -0,0 +1,46 @@ +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 diff --git a/lms/djangoapps/django_comment_client/tests.py b/lms/djangoapps/django_comment_client/tests.py new file mode 100644 index 0000000000..ac18c5b5bf --- /dev/null +++ b/lms/djangoapps/django_comment_client/tests.py @@ -0,0 +1,36 @@ +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 diff --git a/lms/djangoapps/django_comment_client/utils.py b/lms/djangoapps/django_comment_client/utils.py index 8caca7aaa8..dc78802564 100644 --- a/lms/djangoapps/django_comment_client/utils.py +++ b/lms/djangoapps/django_comment_client/utils.py @@ -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=''): diff --git a/lms/static/coffee/src/discussion/content.coffee b/lms/static/coffee/src/discussion/content.coffee index b98c5eb7f0..b195514afa 100644 --- a/lms/static/coffee/src/discussion/content.coffee +++ b/lms/static/coffee/src/discussion/content.coffee @@ -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) diff --git a/lms/static/coffee/src/discussion/utils.coffee b/lms/static/coffee/src/discussion/utils.coffee index 44944f0201..1c0769a72a 100644 --- a/lms/static/coffee/src/discussion/utils.coffee +++ b/lms/static/coffee/src/discussion/utils.coffee @@ -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" diff --git a/lms/templates/discussion/_thread.html b/lms/templates/discussion/_thread.html index 1b9fcbadc3..b044c98da6 100644 --- a/lms/templates/discussion/_thread.html +++ b/lms/templates/discussion/_thread.html @@ -86,6 +86,13 @@ % endif % endif + % if type == "thread" and request.user.is_staff: + % if content['closed']: + Re-open thread + % else: + Close thread + % endif + % endif