Commit b06e46c1 by Christina Roberts

Merge pull request #9113 from edx/christina/team-discussion-permissions

Christina/team discussion permissions
parents 2e53b9e8 bc2892f2
from django_comment_common.models import Role from django_comment_common.models import Role
class ThreadContext(object):
""" An enumeration that represents the context of a thread. Used primarily by the comments service. """
STANDALONE = 'standalone'
COURSE = 'course'
_STUDENT_ROLE_PERMISSIONS = ["vote", "update_thread", "follow_thread", "unfollow_thread", _STUDENT_ROLE_PERMISSIONS = ["vote", "update_thread", "follow_thread", "unfollow_thread",
"update_comment", "create_sub_comment", "unvote", "create_thread", "update_comment", "create_sub_comment", "unvote", "create_thread",
"follow_commentable", "unfollow_commentable", "create_comment", ] "follow_commentable", "unfollow_commentable", "create_comment", ]
......
...@@ -19,7 +19,7 @@ from django_comment_client.tests.group_id import CohortedTopicGroupIdTestMixin, ...@@ -19,7 +19,7 @@ from django_comment_client.tests.group_id import CohortedTopicGroupIdTestMixin,
from django_comment_client.tests.utils import CohortedTestCase from django_comment_client.tests.utils import CohortedTestCase
from django_comment_client.tests.unicode import UnicodeTestMixin from django_comment_client.tests.unicode import UnicodeTestMixin
from django_comment_common.models import Role from django_comment_common.models import Role
from django_comment_common.utils import seed_permissions_roles from django_comment_common.utils import seed_permissions_roles, ThreadContext
from student.tests.factories import CourseEnrollmentFactory, UserFactory, CourseAccessRoleFactory from student.tests.factories import CourseEnrollmentFactory, UserFactory, CourseAccessRoleFactory
from util.testing import UrlResetMixin from util.testing import UrlResetMixin
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
...@@ -28,6 +28,8 @@ from xmodule.modulestore.tests.factories import check_mongo_calls ...@@ -28,6 +28,8 @@ from xmodule.modulestore.tests.factories import check_mongo_calls
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore import ModuleStoreEnum
from teams.tests.factories import CourseTeamFactory
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
...@@ -99,7 +101,8 @@ class ThreadActionGroupIdTestCase( ...@@ -99,7 +101,8 @@ class ThreadActionGroupIdTestCase(
"user_id": str(self.student.id), "user_id": str(self.student.id),
"group_id": self.student_cohort.id, "group_id": self.student_cohort.id,
"closed": False, "closed": False,
"type": "thread" "type": "thread",
"commentable_id": "non_team_dummy_id"
} }
) )
mock_request.return_value.status_code = 200 mock_request.return_value.status_code = 200
...@@ -231,12 +234,13 @@ class ViewsTestCaseMixin(object): ...@@ -231,12 +234,13 @@ class ViewsTestCaseMixin(object):
data = { data = {
"user_id": str(self.student.id), "user_id": str(self.student.id),
"closed": False, "closed": False,
"commentable_id": "non_team_dummy_id"
} }
if include_depth: if include_depth:
data["depth"] = 0 data["depth"] = 0
self._set_mock_request_data(mock_request, data) self._set_mock_request_data(mock_request, data)
def create_thread_helper(self, mock_request, extra_data=None): def create_thread_helper(self, mock_request, extra_request_data=None, extra_response_data=None):
""" """
Issues a request to create a thread and verifies the result. Issues a request to create a thread and verifies the result.
""" """
...@@ -279,8 +283,8 @@ class ViewsTestCaseMixin(object): ...@@ -279,8 +283,8 @@ class ViewsTestCaseMixin(object):
"anonymous": ["false"], "anonymous": ["false"],
"title": ["Hello"], "title": ["Hello"],
} }
if extra_data: if extra_request_data:
thread.update(extra_data) thread.update(extra_request_data)
url = reverse('create_thread', kwargs={'commentable_id': 'i4x-MITx-999-course-Robot_Super_Course', url = reverse('create_thread', kwargs={'commentable_id': 'i4x-MITx-999-course-Robot_Super_Course',
'course_id': self.course_id.to_deprecated_string()}) 'course_id': self.course_id.to_deprecated_string()})
response = self.client.post(url, data=thread) response = self.client.post(url, data=thread)
...@@ -288,14 +292,15 @@ class ViewsTestCaseMixin(object): ...@@ -288,14 +292,15 @@ class ViewsTestCaseMixin(object):
expected_data = { expected_data = {
'thread_type': 'discussion', 'thread_type': 'discussion',
'body': u'this is a post', 'body': u'this is a post',
'context': ThreadContext.COURSE,
'anonymous_to_peers': False, 'user_id': 1, 'anonymous_to_peers': False, 'user_id': 1,
'title': u'Hello', 'title': u'Hello',
'commentable_id': u'i4x-MITx-999-course-Robot_Super_Course', 'commentable_id': u'i4x-MITx-999-course-Robot_Super_Course',
'anonymous': False, 'anonymous': False,
'course_id': unicode(self.course_id), 'course_id': unicode(self.course_id),
} }
if extra_data: if extra_response_data:
expected_data.update(extra_data) expected_data.update(extra_response_data)
mock_request.assert_called_with( mock_request.assert_called_with(
'post', 'post',
'{prefix}/i4x-MITx-999-course-Robot_Super_Course/threads'.format(prefix=CS_PREFIX), '{prefix}/i4x-MITx-999-course-Robot_Super_Course/threads'.format(prefix=CS_PREFIX),
...@@ -347,10 +352,10 @@ class ViewsQueryCountTestCase(UrlResetMixin, ModuleStoreTestCase, MockRequestSet ...@@ -347,10 +352,10 @@ class ViewsQueryCountTestCase(UrlResetMixin, ModuleStoreTestCase, MockRequestSet
return inner return inner
@ddt.data( @ddt.data(
(ModuleStoreEnum.Type.mongo, 3, 4, 21), (ModuleStoreEnum.Type.mongo, 3, 4, 22),
(ModuleStoreEnum.Type.mongo, 20, 4, 21), (ModuleStoreEnum.Type.mongo, 20, 4, 22),
(ModuleStoreEnum.Type.split, 3, 13, 21), (ModuleStoreEnum.Type.split, 3, 13, 22),
(ModuleStoreEnum.Type.split, 20, 13, 21), (ModuleStoreEnum.Type.split, 20, 13, 22),
) )
@ddt.unpack @ddt.unpack
@count_queries @count_queries
...@@ -383,9 +388,19 @@ class ViewsTestCase(UrlResetMixin, ModuleStoreTestCase, MockRequestSetupMixin, V ...@@ -383,9 +388,19 @@ class ViewsTestCase(UrlResetMixin, ModuleStoreTestCase, MockRequestSetupMixin, V
def test_create_thread(self, mock_request): def test_create_thread(self, mock_request):
self.create_thread_helper(mock_request) self.create_thread_helper(mock_request)
def test_create_thread_with_context(self, mock_request): def test_create_thread_standalone(self, mock_request):
team = CourseTeamFactory.create(
name="A Team",
course_id=self.course_id,
topic_id='topic_id',
discussion_topic_id="i4x-MITx-999-course-Robot_Super_Course"
)
# Add the student to the team so they can post to the commentable.
team.add_user(self.student)
# create_thread_helper verifies that extra data are passed through to the comments service # create_thread_helper verifies that extra data are passed through to the comments service
self.create_thread_helper(mock_request, extra_data={'context': 'standalone'}) self.create_thread_helper(mock_request, extra_response_data={'context': ThreadContext.STANDALONE})
def test_delete_comment(self, mock_request): def test_delete_comment(self, mock_request):
self._set_mock_request_data(mock_request, { self._set_mock_request_data(mock_request, {
...@@ -964,7 +979,9 @@ class CreateThreadUnicodeTestCase(ModuleStoreTestCase, UnicodeTestMixin, MockReq ...@@ -964,7 +979,9 @@ class CreateThreadUnicodeTestCase(ModuleStoreTestCase, UnicodeTestMixin, MockReq
request = RequestFactory().post("dummy_url", {"thread_type": "discussion", "body": text, "title": text}) request = RequestFactory().post("dummy_url", {"thread_type": "discussion", "body": text, "title": text})
request.user = self.student request.user = self.student
request.view_name = "create_thread" request.view_name = "create_thread"
response = views.create_thread(request, course_id=self.course.id.to_deprecated_string(), commentable_id="test_commentable") response = views.create_thread(
request, course_id=self.course.id.to_deprecated_string(), commentable_id="non_team_dummy_id"
)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertTrue(mock_request.called) self.assertTrue(mock_request.called)
...@@ -1012,13 +1029,15 @@ class CreateCommentUnicodeTestCase(ModuleStoreTestCase, UnicodeTestMixin, MockRe ...@@ -1012,13 +1029,15 @@ class CreateCommentUnicodeTestCase(ModuleStoreTestCase, UnicodeTestMixin, MockRe
@patch('lms.lib.comment_client.utils.requests.request') @patch('lms.lib.comment_client.utils.requests.request')
def _test_unicode_data(self, text, mock_request): def _test_unicode_data(self, text, mock_request):
commentable_id = "non_team_dummy_id"
self._set_mock_request_data(mock_request, { self._set_mock_request_data(mock_request, {
"closed": False, "closed": False,
"commentable_id": commentable_id
}) })
# We have to get clever here due to Thread's setters and getters. # We have to get clever here due to Thread's setters and getters.
# Patch won't work with it. # Patch won't work with it.
try: try:
Thread.commentable_id = Mock() Thread.commentable_id = commentable_id
request = RequestFactory().post("dummy_url", {"body": text}) request = RequestFactory().post("dummy_url", {"body": text})
request.user = self.student request.user = self.student
request.view_name = "create_comment" request.view_name = "create_comment"
...@@ -1078,7 +1097,8 @@ class CreateSubCommentUnicodeTestCase(ModuleStoreTestCase, UnicodeTestMixin, Moc ...@@ -1078,7 +1097,8 @@ class CreateSubCommentUnicodeTestCase(ModuleStoreTestCase, UnicodeTestMixin, Moc
self._set_mock_request_data(mock_request, { self._set_mock_request_data(mock_request, {
"closed": False, "closed": False,
"depth": 1, "depth": 1,
"thread_id": "test_thread" "thread_id": "test_thread",
"commentable_id": "non_team_dummy_id"
}) })
request = RequestFactory().post("dummy_url", {"body": text}) request = RequestFactory().post("dummy_url", {"body": text})
request.user = self.student request.user = self.student
...@@ -1086,7 +1106,7 @@ class CreateSubCommentUnicodeTestCase(ModuleStoreTestCase, UnicodeTestMixin, Moc ...@@ -1086,7 +1106,7 @@ class CreateSubCommentUnicodeTestCase(ModuleStoreTestCase, UnicodeTestMixin, Moc
Thread.commentable_id = Mock() Thread.commentable_id = Mock()
try: try:
response = views.create_sub_comment( response = views.create_sub_comment(
request, course_id=self.course.id.to_deprecated_string(), comment_id="dummy_comment_id" request, course_id=unicode(self.course.id), comment_id="dummy_comment_id"
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
...@@ -1096,6 +1116,222 @@ class CreateSubCommentUnicodeTestCase(ModuleStoreTestCase, UnicodeTestMixin, Moc ...@@ -1096,6 +1116,222 @@ class CreateSubCommentUnicodeTestCase(ModuleStoreTestCase, UnicodeTestMixin, Moc
del Thread.commentable_id del Thread.commentable_id
@ddt.ddt
@patch("lms.lib.comment_client.utils.requests.request")
class TeamsPermissionsTestCase(UrlResetMixin, ModuleStoreTestCase, MockRequestSetupMixin):
# Most of the test points use the same ddt data.
# args: user, commentable_id, status_code
ddt_permissions_args = [
# Student in team can do operations on threads/comments within the team commentable.
('student_in_team', 'team_commentable_id', 200),
# Non-team commentables can be edited by any student.
('student_in_team', 'course_commentable_id', 200),
# Student not in team cannot do operations within the team commentable.
('student_not_in_team', 'team_commentable_id', 401),
# Non-team commentables can be edited by any student.
('student_not_in_team', 'course_commentable_id', 200),
# Moderators can always operator on threads within a team, regardless of team membership.
('moderator', 'team_commentable_id', 200)
]
@patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True})
def setUp(self):
super(TeamsPermissionsTestCase, self).setUp()
self.password = "test password"
teams_configuration = {
'topics': [{'id': "topic_id", 'name': 'Solar Power', 'description': 'Solar power is hot'}]
}
self.course = CourseFactory.create(teams_configuration=teams_configuration)
seed_permissions_roles(self.course.id)
# Create 3 users-- student in team, student not in team, discussion moderator
self.student_in_team = UserFactory.create(password=self.password)
self.student_not_in_team = UserFactory.create(password=self.password)
self.moderator = UserFactory.create(password=self.password)
CourseEnrollmentFactory(user=self.student_in_team, course_id=self.course.id)
CourseEnrollmentFactory(user=self.student_not_in_team, course_id=self.course.id)
CourseEnrollmentFactory(user=self.moderator, course_id=self.course.id)
self.moderator.roles.add(Role.objects.get(name="Moderator", course_id=self.course.id))
# Create a team.
self.team_commentable_id = "team_discussion_id"
self.team = CourseTeamFactory.create(
name=u'The Only Team',
course_id=self.course.id,
topic_id='topic_id',
discussion_topic_id=self.team_commentable_id
)
self.team.add_user(self.student_in_team)
# Dummy commentable ID not linked to a team
self.course_commentable_id = "course_level_commentable"
def _setup_mock(self, user, mock_request, data):
user = getattr(self, user)
self._set_mock_request_data(mock_request, data)
self.client.login(username=user.username, password=self.password)
@ddt.data(
# student_in_team will be able to update his own post, regardless of team membership
('student_in_team', 'student_in_team', 'team_commentable_id', 200),
('student_in_team', 'student_in_team', 'course_commentable_id', 200),
# students can only update their own posts
('student_in_team', 'moderator', 'team_commentable_id', 401),
# Even though student_not_in_team is not in the team, he can still modify posts he created while in the team.
('student_not_in_team', 'student_not_in_team', 'team_commentable_id', 200),
# Moderators can change their own posts and other people's posts.
('moderator', 'moderator', 'team_commentable_id', 200),
('moderator', 'student_in_team', 'team_commentable_id', 200),
)
@ddt.unpack
def test_update_thread(self, user, thread_author, commentable_id, status_code, mock_request):
"""
Verify that update_thread is limited to thread authors and privileged users (team membership does not matter).
"""
commentable_id = getattr(self, commentable_id)
# thread_author is who is marked as the author of the thread being updated.
thread_author = getattr(self, thread_author)
self._setup_mock(
user, mock_request, # user is the person making the request.
{"user_id": str(thread_author.id), "closed": False, "commentable_id": commentable_id}
)
response = self.client.post(
reverse(
"update_thread",
kwargs={
"course_id": unicode(self.course.id),
"thread_id": "dummy"
}
),
data={"body": "foo", "title": "foo"}
)
self.assertEqual(response.status_code, status_code)
@ddt.data(*ddt_permissions_args)
@ddt.unpack
def test_create_comment(self, user, commentable_id, status_code, mock_request):
"""
Verify that create_comment is limited to members of the team or users with 'edit_content' permission.
"""
commentable_id = getattr(self, commentable_id)
self._setup_mock(user, mock_request, {"closed": False, "commentable_id": commentable_id})
response = self.client.post(
reverse(
"create_comment",
kwargs={
"course_id": unicode(self.course.id),
"thread_id": "dummy"
}
),
data={"body": "foo", "title": "foo"}
)
self.assertEqual(response.status_code, status_code)
@ddt.data(*ddt_permissions_args)
@ddt.unpack
def test_create_sub_comment(self, user, commentable_id, status_code, mock_request):
"""
Verify that create_subcomment is limited to members of the team or users with 'edit_content' permission.
"""
commentable_id = getattr(self, commentable_id)
self._setup_mock(
user, mock_request,
{"closed": False, "commentable_id": commentable_id, "thread_id": "dummy_thread"},
)
response = self.client.post(
reverse(
"create_sub_comment",
kwargs={
"course_id": unicode(self.course.id),
"comment_id": "dummy_comment"
}
),
data={"body": "foo", "title": "foo"}
)
self.assertEqual(response.status_code, status_code)
@ddt.data(*ddt_permissions_args)
@ddt.unpack
def test_comment_actions(self, user, commentable_id, status_code, mock_request):
"""
Verify that voting and flagging of comments is limited to members of the team or users with
'edit_content' permission.
"""
commentable_id = getattr(self, commentable_id)
self._setup_mock(
user, mock_request,
{"closed": False, "commentable_id": commentable_id, "thread_id": "dummy_thread"},
)
for action in ["upvote_comment", "downvote_comment", "un_flag_abuse_for_comment", "flag_abuse_for_comment"]:
response = self.client.post(
reverse(
action,
kwargs={"course_id": unicode(self.course.id), "comment_id": "dummy_comment"}
)
)
self.assertEqual(response.status_code, status_code)
@ddt.data(*ddt_permissions_args)
@ddt.unpack
def test_threads_actions(self, user, commentable_id, status_code, mock_request):
"""
Verify that voting, flagging, and following of threads is limited to members of the team or users with
'edit_content' permission.
"""
commentable_id = getattr(self, commentable_id)
self._setup_mock(
user, mock_request,
{"closed": False, "commentable_id": commentable_id},
)
for action in ["upvote_thread", "downvote_thread", "un_flag_abuse_for_thread", "flag_abuse_for_thread",
"follow_thread", "unfollow_thread"]:
response = self.client.post(
reverse(
action,
kwargs={"course_id": unicode(self.course.id), "thread_id": "dummy_thread"}
)
)
self.assertEqual(response.status_code, status_code)
@ddt.data(*ddt_permissions_args)
@ddt.unpack
def test_create_thread(self, user, commentable_id, status_code, __):
"""
Verify that creation of threads is limited to members of the team or users with 'edit_content' permission.
"""
commentable_id = getattr(self, commentable_id)
# mock_request is not used because Commentables don't exist in comment service.
self.client.login(username=getattr(self, user).username, password=self.password)
response = self.client.post(
reverse(
"create_thread",
kwargs={"course_id": unicode(self.course.id), "commentable_id": commentable_id}
),
data={"body": "foo", "title": "foo", "thread_type": "discussion"}
)
self.assertEqual(response.status_code, status_code)
@ddt.data(*ddt_permissions_args)
@ddt.unpack
def test_commentable_actions(self, user, commentable_id, status_code, __):
"""
Verify that following of commentables is limited to members of the team or users with
'edit_content' permission.
"""
commentable_id = getattr(self, commentable_id)
# mock_request is not used because Commentables don't exist in comment service.
self.client.login(username=getattr(self, user).username, password=self.password)
for action in ["follow_commentable", "unfollow_commentable"]:
response = self.client.post(
reverse(
action,
kwargs={"course_id": unicode(self.course.id), "commentable_id": commentable_id}
)
)
self.assertEqual(response.status_code, status_code)
class ForumEventTestCase(ModuleStoreTestCase, MockRequestSetupMixin): class ForumEventTestCase(ModuleStoreTestCase, MockRequestSetupMixin):
""" """
Forum actions are expected to launch analytics events. Test these here. Forum actions are expected to launch analytics events. Test these here.
...@@ -1123,7 +1359,7 @@ class ForumEventTestCase(ModuleStoreTestCase, MockRequestSetupMixin): ...@@ -1123,7 +1359,7 @@ class ForumEventTestCase(ModuleStoreTestCase, MockRequestSetupMixin):
request.user = self.student request.user = self.student
request.view_name = "create_thread" request.view_name = "create_thread"
views.create_thread(request, course_id=self.course.id.to_deprecated_string(), commentable_id="test_commentable") views.create_thread(request, course_id=unicode(self.course.id), commentable_id="test_commentable")
event_name, event = mock_emit.call_args[0] event_name, event = mock_emit.call_args[0]
self.assertEqual(event_name, 'edx.forum.thread.created') self.assertEqual(event_name, 'edx.forum.thread.created')
...@@ -1180,9 +1416,7 @@ class ForumEventTestCase(ModuleStoreTestCase, MockRequestSetupMixin): ...@@ -1180,9 +1416,7 @@ class ForumEventTestCase(ModuleStoreTestCase, MockRequestSetupMixin):
request = RequestFactory().post("dummy_url", {"body": "Another comment"}) request = RequestFactory().post("dummy_url", {"body": "Another comment"})
request.user = self.student request.user = self.student
request.view_name = "create_sub_comment" request.view_name = "create_sub_comment"
views.create_sub_comment( views.create_sub_comment(request, course_id=unicode(self.course.id), comment_id="dummy_comment_id")
request, course_id=self.course.id.to_deprecated_string(), comment_id="dummy_comment_id"
)
event_name, event = mock_emit.call_args[0] event_name, event = mock_emit.call_args[0]
self.assertEqual(event_name, "edx.forum.comment.created") self.assertEqual(event_name, "edx.forum.comment.created")
......
...@@ -18,6 +18,7 @@ from courseware.access import has_access ...@@ -18,6 +18,7 @@ from courseware.access import has_access
from util.file import store_uploaded_file from util.file import store_uploaded_file
from courseware.courses import get_course_with_access, get_course_by_id from courseware.courses import get_course_with_access, get_course_by_id
import django_comment_client.settings as cc_settings import django_comment_client.settings as cc_settings
from django_comment_common.utils import ThreadContext
from django_comment_client.utils import ( from django_comment_client.utils import (
add_courseware_context, add_courseware_context,
get_annotated_content_info, get_annotated_content_info,
...@@ -30,7 +31,7 @@ from django_comment_client.utils import ( ...@@ -30,7 +31,7 @@ from django_comment_client.utils import (
discussion_category_id_access, discussion_category_id_access,
get_cached_discussion_id_map, get_cached_discussion_id_map,
) )
from django_comment_client.permissions import check_permissions_by_view, has_permission from django_comment_client.permissions import check_permissions_by_view, has_permission, get_team
from eventtracking import tracker from eventtracking import tracker
import lms.lib.comment_client as cc import lms.lib.comment_client as cc
...@@ -51,6 +52,8 @@ def permitted(fn): ...@@ -51,6 +52,8 @@ def permitted(fn):
content = cc.Thread.find(kwargs["thread_id"]).to_dict() content = cc.Thread.find(kwargs["thread_id"]).to_dict()
elif "comment_id" in kwargs: elif "comment_id" in kwargs:
content = cc.Comment.find(kwargs["comment_id"]).to_dict() content = cc.Comment.find(kwargs["comment_id"]).to_dict()
elif "commentable_id" in kwargs:
content = cc.Commentable.find(kwargs["commentable_id"]).to_dict()
else: else:
content = None content = None
return content return content
...@@ -185,8 +188,11 @@ def create_thread(request, course_id, commentable_id): ...@@ -185,8 +188,11 @@ def create_thread(request, course_id, commentable_id):
'title': post["title"], 'title': post["title"],
} }
if 'context' in post: # Check for whether this commentable belongs to a team, and add the right context
params['context'] = post['context'] if get_team(commentable_id) is not None:
params['context'] = ThreadContext.STANDALONE
else:
params['context'] = ThreadContext.COURSE
thread = cc.Thread(**params) thread = cc.Thread(**params)
...@@ -608,16 +614,6 @@ def follow_commentable(request, course_id, commentable_id): ...@@ -608,16 +614,6 @@ def follow_commentable(request, course_id, commentable_id):
@require_POST @require_POST
@login_required @login_required
@permitted @permitted
def follow_user(request, course_id, followed_user_id):
user = cc.User.from_django_user(request.user)
followed_user = cc.User.find(followed_user_id)
user.follow(followed_user)
return JsonResponse({})
@require_POST
@login_required
@permitted
def unfollow_thread(request, course_id, thread_id): def unfollow_thread(request, course_id, thread_id):
""" """
given a course id and thread id, stop following this thread given a course id and thread id, stop following this thread
...@@ -645,20 +641,6 @@ def unfollow_commentable(request, course_id, commentable_id): ...@@ -645,20 +641,6 @@ def unfollow_commentable(request, course_id, commentable_id):
@require_POST @require_POST
@login_required @login_required
@permitted
def unfollow_user(request, course_id, followed_user_id):
"""
given a course id and user id, stop following this user
ajax only
"""
user = cc.User.from_django_user(request.user)
followed_user = cc.User.find(followed_user_id)
user.unfollow(followed_user)
return JsonResponse({})
@require_POST
@login_required
@csrf.csrf_exempt @csrf.csrf_exempt
def upload(request, course_id): # ajax upload file to a question or answer def upload(request, course_id): # ajax upload file to a question or answer
"""view that handles file upload via Ajax """view that handles file upload via Ajax
......
...@@ -8,7 +8,9 @@ from django.test.client import Client, RequestFactory ...@@ -8,7 +8,9 @@ from django.test.client import Client, RequestFactory
from django.test.utils import override_settings from django.test.utils import override_settings
from edxmako.tests import mako_middleware_process_request from edxmako.tests import mako_middleware_process_request
from django_comment_common.utils import ThreadContext
from django_comment_client.forum import views from django_comment_client.forum import views
from django_comment_client.permissions import get_team
from django_comment_client.tests.group_id import ( from django_comment_client.tests.group_id import (
CohortedTopicGroupIdTestMixin, CohortedTopicGroupIdTestMixin,
NonCohortedTopicGroupIdTestMixin NonCohortedTopicGroupIdTestMixin
...@@ -33,6 +35,8 @@ from mock import patch, Mock, ANY, call ...@@ -33,6 +35,8 @@ from mock import patch, Mock, ANY, call
from openedx.core.djangoapps.course_groups.models import CourseUserGroup from openedx.core.djangoapps.course_groups.models import CourseUserGroup
from teams.tests.factories import CourseTeamFactory
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
# pylint: disable=missing-docstring # pylint: disable=missing-docstring
...@@ -112,21 +116,23 @@ def make_mock_thread_data( ...@@ -112,21 +116,23 @@ def make_mock_thread_data(
group_id=None, group_id=None,
group_name=None, group_name=None,
commentable_id=None, commentable_id=None,
context=None
): ):
data_commentable_id = (
commentable_id or course.discussion_topics.get('General', {}).get('id') or "dummy_commentable_id"
)
thread_data = { thread_data = {
"id": thread_id, "id": thread_id,
"type": "thread", "type": "thread",
"title": text, "title": text,
"body": text, "body": text,
"commentable_id": ( "commentable_id": data_commentable_id,
commentable_id or course.discussion_topics.get('General', {}).get('id') or "dummy_commentable_id"
),
"resp_total": 42, "resp_total": 42,
"resp_skip": 25, "resp_skip": 25,
"resp_limit": 5, "resp_limit": 5,
"group_id": group_id, "group_id": group_id,
"context": context if context else "course" "context": (
ThreadContext.COURSE if get_team(data_commentable_id) is None else ThreadContext.STANDALONE
)
} }
if group_id is not None: if group_id is not None:
thread_data['group_name'] = group_name thread_data['group_name'] = group_name
...@@ -146,7 +152,6 @@ def make_mock_request_impl( ...@@ -146,7 +152,6 @@ def make_mock_request_impl(
group_id=None, group_id=None,
commentable_id=None, commentable_id=None,
num_thread_responses=1, num_thread_responses=1,
context=None
): ):
def mock_request_impl(*args, **kwargs): def mock_request_impl(*args, **kwargs):
url = args[1] url = args[1]
...@@ -161,7 +166,6 @@ def make_mock_request_impl( ...@@ -161,7 +166,6 @@ def make_mock_request_impl(
num_children=None, num_children=None,
group_id=group_id, group_id=group_id,
commentable_id=commentable_id, commentable_id=commentable_id,
context=context
) )
] ]
} }
...@@ -172,7 +176,6 @@ def make_mock_request_impl( ...@@ -172,7 +176,6 @@ def make_mock_request_impl(
thread_id=thread_id, thread_id=thread_id,
num_children=num_thread_responses, num_children=num_thread_responses,
group_id=group_id, group_id=group_id,
context=context
) )
elif "/users/" in url: elif "/users/" in url:
data = { data = {
...@@ -333,11 +336,11 @@ class SingleThreadQueryCountTestCase(ModuleStoreTestCase): ...@@ -333,11 +336,11 @@ class SingleThreadQueryCountTestCase(ModuleStoreTestCase):
@ddt.data( @ddt.data(
# old mongo with cache # old mongo with cache
(ModuleStoreEnum.Type.mongo, 1, 7, 5, 13, 8), (ModuleStoreEnum.Type.mongo, 1, 7, 5, 14, 8),
(ModuleStoreEnum.Type.mongo, 50, 7, 5, 13, 8), (ModuleStoreEnum.Type.mongo, 50, 7, 5, 14, 8),
# split mongo: 3 queries, regardless of thread response size. # split mongo: 3 queries, regardless of thread response size.
(ModuleStoreEnum.Type.split, 1, 3, 3, 13, 8), (ModuleStoreEnum.Type.split, 1, 3, 3, 14, 8),
(ModuleStoreEnum.Type.split, 50, 3, 3, 13, 8), (ModuleStoreEnum.Type.split, 50, 3, 3, 14, 8),
) )
@ddt.unpack @ddt.unpack
def test_number_of_mongo_queries( def test_number_of_mongo_queries(
...@@ -672,12 +675,20 @@ class InlineDiscussionContextTestCase(ModuleStoreTestCase): ...@@ -672,12 +675,20 @@ class InlineDiscussionContextTestCase(ModuleStoreTestCase):
super(InlineDiscussionContextTestCase, self).setUp() super(InlineDiscussionContextTestCase, self).setUp()
self.course = CourseFactory.create() self.course = CourseFactory.create()
CourseEnrollmentFactory(user=self.user, course_id=self.course.id) CourseEnrollmentFactory(user=self.user, course_id=self.course.id)
self.discussion_topic_id = "dummy_topic"
self.team = CourseTeamFactory(
name="A team",
course_id=self.course.id,
topic_id='topic_id',
discussion_topic_id=self.discussion_topic_id
)
self.team.add_user(self.user) # pylint: disable=no-member
def test_context_can_be_standalone(self, mock_request): def test_context_can_be_standalone(self, mock_request):
mock_request.side_effect = make_mock_request_impl( mock_request.side_effect = make_mock_request_impl(
course=self.course, course=self.course,
text="dummy text", text="dummy text",
context="standalone" commentable_id=self.discussion_topic_id
) )
request = RequestFactory().get("dummy_url") request = RequestFactory().get("dummy_url")
...@@ -686,11 +697,11 @@ class InlineDiscussionContextTestCase(ModuleStoreTestCase): ...@@ -686,11 +697,11 @@ class InlineDiscussionContextTestCase(ModuleStoreTestCase):
response = views.inline_discussion( response = views.inline_discussion(
request, request,
unicode(self.course.id), unicode(self.course.id),
"dummy_topic", self.discussion_topic_id,
) )
json_response = json.loads(response.content) json_response = json.loads(response.content)
self.assertEqual(json_response['discussion_data'][0]['context'], 'standalone') self.assertEqual(json_response['discussion_data'][0]['context'], ThreadContext.STANDALONE)
@patch('lms.lib.comment_client.utils.requests.request') @patch('lms.lib.comment_client.utils.requests.request')
...@@ -1041,8 +1052,15 @@ class InlineDiscussionTestCase(ModuleStoreTestCase): ...@@ -1041,8 +1052,15 @@ class InlineDiscussionTestCase(ModuleStoreTestCase):
self.verify_response(self.send_request(mock_request)) self.verify_response(self.send_request(mock_request))
def test_context(self, mock_request): def test_context(self, mock_request):
response = self.send_request(mock_request, {'context': 'standalone'}) team = CourseTeamFactory(
self.assertEqual(mock_request.call_args[1]['params']['context'], 'standalone') name='Team Name',
topic_id='A topic',
course_id=self.course.id,
discussion_topic_id=self.discussion1.discussion_id
)
team.add_user(self.student) # pylint: disable=no-member
response = self.send_request(mock_request)
self.assertEqual(mock_request.call_args[1]['params']['context'], ThreadContext.STANDALONE)
self.verify_response(response) self.verify_response(response)
......
...@@ -29,7 +29,8 @@ from courseware.access import has_access ...@@ -29,7 +29,8 @@ from courseware.access import has_access
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from ccx.overrides import get_current_ccx from ccx.overrides import get_current_ccx
from django_comment_client.permissions import has_permission from django_comment_common.utils import ThreadContext
from django_comment_client.permissions import has_permission, get_team
from django_comment_client.utils import ( from django_comment_client.utils import (
merge_dict, merge_dict,
extract, extract,
...@@ -111,6 +112,7 @@ def get_threads(request, course, discussion_id=None, per_page=THREADS_PER_PAGE): ...@@ -111,6 +112,7 @@ def get_threads(request, course, discussion_id=None, per_page=THREADS_PER_PAGE):
'text': '', 'text': '',
'course_id': unicode(course.id), 'course_id': unicode(course.id),
'user_id': request.user.id, 'user_id': request.user.id,
'context': ThreadContext.COURSE,
'group_id': get_group_id_for_comments_service(request, course.id, discussion_id), # may raise ValueError 'group_id': get_group_id_for_comments_service(request, course.id, discussion_id), # may raise ValueError
} }
...@@ -118,6 +120,9 @@ def get_threads(request, course, discussion_id=None, per_page=THREADS_PER_PAGE): ...@@ -118,6 +120,9 @@ def get_threads(request, course, discussion_id=None, per_page=THREADS_PER_PAGE):
# comments_service. # comments_service.
if discussion_id is not None: if discussion_id is not None:
default_query_params['commentable_id'] = discussion_id default_query_params['commentable_id'] = discussion_id
# Use the discussion id/commentable id to determine the context we are going to pass through to the backend.
if get_team(discussion_id) is not None:
default_query_params['context'] = ThreadContext.STANDALONE
if not request.GET.get('sort_key'): if not request.GET.get('sort_key'):
# If the user did not select a sort key, use their last used sort key # If the user did not select a sort key, use their last used sort key
...@@ -149,7 +154,6 @@ def get_threads(request, course, discussion_id=None, per_page=THREADS_PER_PAGE): ...@@ -149,7 +154,6 @@ def get_threads(request, course, discussion_id=None, per_page=THREADS_PER_PAGE):
'flagged', 'flagged',
'unread', 'unread',
'unanswered', 'unanswered',
'context',
] ]
) )
) )
......
...@@ -10,6 +10,7 @@ from lms.lib.comment_client import Thread ...@@ -10,6 +10,7 @@ from lms.lib.comment_client import Thread
from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.keys import CourseKey
from django_comment_common.models import all_permissions_for_user_in_course from django_comment_common.models import all_permissions_for_user_in_course
from teams.models import CourseTeam
def has_permission(user, permission, course_id=None): def has_permission(user, permission, course_id=None):
...@@ -27,23 +28,43 @@ def has_permission(user, permission, course_id=None): ...@@ -27,23 +28,43 @@ def has_permission(user, permission, course_id=None):
return permission in all_permissions return permission in all_permissions
CONDITIONS = ['is_open', 'is_author', 'is_question_author'] CONDITIONS = ['is_open', 'is_author', 'is_question_author', 'is_team_member_if_applicable']
def get_team(commentable_id):
""" Returns the team that the commentable_id belongs to if it exists. Returns None otherwise. """
request_cache_dict = RequestCache.get_request_cache().data
cache_key = "django_comment_client.team_commentable.{}".format(commentable_id)
if cache_key in request_cache_dict:
return request_cache_dict[cache_key]
try:
team = CourseTeam.objects.get(discussion_topic_id=commentable_id)
except CourseTeam.DoesNotExist:
team = None
request_cache_dict[cache_key] = team
return team
def _check_condition(user, condition, content): def _check_condition(user, condition, content):
def check_open(user, content): """ Check whether or not the given condition applies for the given user and content. """
def check_open(_user, content):
""" Check whether the content is open. """
try: try:
return content and not content['closed'] return content and not content['closed']
except KeyError: except KeyError:
return False return False
def check_author(user, content): def check_author(user, content):
""" Check if the given user is the author of the content. """
try: try:
return content and content['user_id'] == str(user.id) return content and content['user_id'] == str(user.id)
except KeyError: except KeyError:
return False return False
def check_question_author(user, content): def check_question_author(user, content):
""" Check if the given user is the author of the original question for both threads and comments. """
if not content: if not content:
return False return False
try: try:
...@@ -55,10 +76,36 @@ def _check_condition(user, condition, content): ...@@ -55,10 +76,36 @@ def _check_condition(user, condition, content):
except KeyError: except KeyError:
return False return False
def check_team_member(user, content):
"""
If the content has a commentable_id, verifies that either it is not associated with a team,
or if it is, that the user is a member of that team.
"""
if not content:
return False
try:
commentable_id = content['commentable_id']
request_cache_dict = RequestCache.get_request_cache().data
cache_key = "django_comment_client.check_team_member.{}.{}".format(user.id, commentable_id)
if cache_key in request_cache_dict:
return request_cache_dict[cache_key]
team = get_team(commentable_id)
if team is None:
passes_condition = True
else:
passes_condition = team.users.filter(id=user.id).exists()
request_cache_dict[cache_key] = passes_condition
except KeyError:
# We do not expect KeyError in production-- it usually indicates an improper test mock.
logging.warning("Did not find key commentable_id in content.")
passes_condition = False
return passes_condition
handlers = { handlers = {
'is_open': check_open, 'is_open': check_open,
'is_author': check_author, 'is_author': check_author,
'is_question_author': check_question_author, 'is_question_author': check_question_author,
'is_team_member_if_applicable': check_team_member
} }
return handlers[condition](user, content) return handlers[condition](user, content)
...@@ -86,32 +133,32 @@ def _check_conditions_permissions(user, permissions, course_id, content): ...@@ -86,32 +133,32 @@ def _check_conditions_permissions(user, permissions, course_id, content):
return test(user, permissions, operator="or") return test(user, permissions, operator="or")
# Note: 'edit_content' is being used as a generic way of telling if someone is a privileged user
# (forum Moderator/Admin/TA), because there is a desire that team membership does not impact privileged users.
VIEW_PERMISSIONS = { VIEW_PERMISSIONS = {
'update_thread': ['edit_content', ['update_thread', 'is_open', 'is_author']], 'update_thread': ['edit_content', ['update_thread', 'is_open', 'is_author']],
'create_comment': [["create_comment", "is_open"]], 'create_comment': ['edit_content', ["create_comment", "is_open", "is_team_member_if_applicable"]],
'delete_thread': ['delete_thread', ['update_thread', 'is_author']], 'delete_thread': ['delete_thread', ['update_thread', 'is_author']],
'update_comment': ['edit_content', ['update_comment', 'is_open', 'is_author']], 'update_comment': ['edit_content', ['update_comment', 'is_open', 'is_author']],
'endorse_comment': ['endorse_comment', 'is_question_author'], 'endorse_comment': ['endorse_comment', 'is_question_author'],
'openclose_thread': ['openclose_thread'], 'openclose_thread': ['openclose_thread'],
'create_sub_comment': [['create_sub_comment', 'is_open']], 'create_sub_comment': ['edit_content', ['create_sub_comment', 'is_open', 'is_team_member_if_applicable']],
'delete_comment': ['delete_comment', ['update_comment', 'is_open', 'is_author']], 'delete_comment': ['delete_comment', ['update_comment', 'is_open', 'is_author']],
'vote_for_comment': [['vote', 'is_open']], 'vote_for_comment': ['edit_content', ['vote', 'is_open', 'is_team_member_if_applicable']],
'undo_vote_for_comment': [['unvote', 'is_open']], 'undo_vote_for_comment': ['edit_content', ['unvote', 'is_open', 'is_team_member_if_applicable']],
'vote_for_thread': [['vote', 'is_open']], 'vote_for_thread': ['edit_content', ['vote', 'is_open', 'is_team_member_if_applicable']],
'flag_abuse_for_thread': ['vote'], 'flag_abuse_for_thread': ['edit_content', ['vote', 'is_team_member_if_applicable']],
'un_flag_abuse_for_thread': ['vote'], 'un_flag_abuse_for_thread': ['edit_content', ['vote', 'is_team_member_if_applicable']],
'flag_abuse_for_comment': ['vote'], 'flag_abuse_for_comment': ['edit_content', ['vote', 'is_team_member_if_applicable']],
'un_flag_abuse_for_comment': ['vote'], 'un_flag_abuse_for_comment': ['edit_content', ['vote', 'is_team_member_if_applicable']],
'undo_vote_for_thread': [['unvote', 'is_open']], 'undo_vote_for_thread': ['edit_content', ['unvote', 'is_open', 'is_team_member_if_applicable']],
'pin_thread': ['openclose_thread'], 'pin_thread': ['openclose_thread'],
'un_pin_thread': ['openclose_thread'], 'un_pin_thread': ['openclose_thread'],
'follow_thread': ['follow_thread'], 'follow_thread': ['edit_content', ['follow_thread', 'is_team_member_if_applicable']],
'follow_commentable': ['follow_commentable'], 'follow_commentable': ['edit_content', ['follow_commentable', 'is_team_member_if_applicable']],
'follow_user': ['follow_user'], 'unfollow_thread': ['edit_content', ['unfollow_thread', 'is_team_member_if_applicable']],
'unfollow_thread': ['unfollow_thread'], 'unfollow_commentable': ['edit_content', ['unfollow_commentable', 'is_team_member_if_applicable']],
'unfollow_commentable': ['unfollow_commentable'], 'create_thread': ['edit_content', ['create_thread', 'is_team_member_if_applicable']],
'unfollow_user': ['unfollow_user'],
'create_thread': ['create_thread'],
} }
......
...@@ -5,5 +5,15 @@ from lms.lib.comment_client import settings ...@@ -5,5 +5,15 @@ from lms.lib.comment_client import settings
class Commentable(models.Model): class Commentable(models.Model):
accessible_fields = ['id', 'commentable_id']
base_url = "{prefix}/commentables".format(prefix=settings.PREFIX) base_url = "{prefix}/commentables".format(prefix=settings.PREFIX)
type = 'commentable' type = 'commentable'
def retrieve(self, *args, **kwargs):
"""
Override default behavior because commentables don't actually exist in the comment service.
"""
self.attributes["commentable_id"] = self.attributes["id"]
self.retrieved = True
return self
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