From cd3bc236eb8d38c64acdedf74367dfa7cecefe59 Mon Sep 17 00:00:00 2001 From: Kyle McCormick Date: Fri, 4 Oct 2019 16:40:13 -0400 Subject: [PATCH] Make the team discussion thread private Develop the capability to allow instructors to designate teams to have private discussions. This way, so non-teammembers cannot view discussion. And the intend is for course teams to manage the team membership as well. MST-10 --- .../views/discussion_inline_view.js | 36 +-- lms/djangoapps/discussion/exceptions.py | 11 + .../discussion_private_fragment.html | 20 ++ lms/djangoapps/discussion/tests/test_views.py | 87 +++++++- lms/djangoapps/discussion/views.py | 48 +++- lms/djangoapps/teams/api.py | 52 +++++ .../js/spec/views/team_discussion_spec.js | 211 +++--------------- .../js/spec_helpers/team_spec_helpers.js | 5 +- lms/djangoapps/teams/tests/test_api.py | 78 +++++++ 9 files changed, 338 insertions(+), 210 deletions(-) create mode 100644 lms/djangoapps/discussion/exceptions.py create mode 100644 lms/djangoapps/discussion/templates/discussion/discussion_private_fragment.html create mode 100644 lms/djangoapps/teams/api.py create mode 100644 lms/djangoapps/teams/tests/test_api.py diff --git a/common/static/common/js/discussion/views/discussion_inline_view.js b/common/static/common/js/discussion/views/discussion_inline_view.js index 670a8b6fee..67248861da 100644 --- a/common/static/common/js/discussion/views/discussion_inline_view.js +++ b/common/static/common/js/discussion/views/discussion_inline_view.js @@ -176,9 +176,15 @@ this.threadListView.$('.is-active').focus(); }, + hideDiscussion: function() { + this.$('section.discussion').addClass('is-hidden'); + this.toggleDiscussionBtn.removeClass('shown'); + this.toggleDiscussionBtn.find('.button-text').text(gettext('Show Discussion')); + this.showed = false; + }, + toggleDiscussion: function() { var self = this; - if (this.showed) { this.hideDiscussion(); } else { @@ -188,25 +194,27 @@ this.$('section.discussion').removeClass('is-hidden'); this.showed = true; } else { - this.loadDiscussions(this.$el, function() { - self.hideDiscussion(); - DiscussionUtil.discussionAlert( - gettext('Error'), - gettext('This discussion could not be loaded. Refresh the page and try again.') - ); + this.loadDiscussions(this.$el, function(request) { + if (request.status === 403 && request.responseText) { + DiscussionUtil.discussionAlert( + gettext('Warning'), + request.responseText + ); + self.$el.text(request.responseText); + self.showed = true; + } else { + self.hideDiscussion(); + DiscussionUtil.discussionAlert( + gettext('Error'), + gettext('This discussion could not be loaded. Refresh the page and try again.') + ); + } }); } } this.toggleDiscussionBtn.focus(); }, - hideDiscussion: function() { - this.$('section.discussion').addClass('is-hidden'); - this.toggleDiscussionBtn.removeClass('shown'); - this.toggleDiscussionBtn.find('.button-text').text(gettext('Show Discussion')); - this.showed = false; - }, - toggleNewPost: function(event) { event.preventDefault(); if (!this.newPostForm) { diff --git a/lms/djangoapps/discussion/exceptions.py b/lms/djangoapps/discussion/exceptions.py new file mode 100644 index 0000000000..4407e16509 --- /dev/null +++ b/lms/djangoapps/discussion/exceptions.py @@ -0,0 +1,11 @@ +""" +Custom exceptions raised by Discussion API. +""" + + +class TeamDiscussionHiddenFromUserException(BaseException): + """ + This is the exception raised when a user is not + permitted to view the discussion thread + """ + pass diff --git a/lms/djangoapps/discussion/templates/discussion/discussion_private_fragment.html b/lms/djangoapps/discussion/templates/discussion/discussion_private_fragment.html new file mode 100644 index 0000000000..6ba50e5dd2 --- /dev/null +++ b/lms/djangoapps/discussion/templates/discussion/discussion_private_fragment.html @@ -0,0 +1,20 @@ +## mako + +<%page expression_filter="h"/> + +<%! +from django.utils.translation import ugettext as _ +%> + +<%block name="content"> +

${_("Private Discussion")}

+ + diff --git a/lms/djangoapps/discussion/tests/test_views.py b/lms/djangoapps/discussion/tests/test_views.py index 6a55025ce7..f8fc0b51f3 100644 --- a/lms/djangoapps/discussion/tests/test_views.py +++ b/lms/djangoapps/discussion/tests/test_views.py @@ -415,6 +415,38 @@ class SingleThreadTestCase(ForumsEnableMixin, ModuleStoreTestCase): "test_thread_id" ) + def test_private_team_thread_html(self, mock_request): + discussion_topic_id = 'dummy_discussion_id' + thread_id = 'test_thread_id' + CourseTeamFactory.create(discussion_topic_id=discussion_topic_id) + user_not_in_team = UserFactory.create() + CourseEnrollmentFactory.create(user=user_not_in_team, course_id=self.course.id) + self.client.login(username=user_not_in_team.username, password='test') + + mock_request.side_effect = make_mock_request_impl( + course=self.course, + text="dummy", + thread_id=thread_id, + commentable_id=discussion_topic_id + ) + with patch('lms.djangoapps.teams.api.is_team_discussion_private', autospec=True) as mocked: + mocked.return_value = True + response = self.client.get( + reverse('single_thread', kwargs={ + 'course_id': six.text_type(self.course.id), + 'discussion_id': discussion_topic_id, + 'thread_id': thread_id, + }) + ) + self.assertEquals(response.status_code, 200) + self.assertEqual(response['Content-Type'], 'text/html; charset=utf-8') + html = response.content.decode('utf-8') + # Verify that the access denied error message is in the HTML + self.assertIn( + 'This is a private discussion. You do not have permissions to view this discussion', + html + ) + @ddt.ddt @patch('requests.request', autospec=True) @@ -432,18 +464,18 @@ class SingleThreadQueryCountTestCase(ForumsEnableMixin, ModuleStoreTestCase): # course is outside the context manager that is verifying the number of queries, # and with split mongo, that method ends up querying disabled_xblocks (which is then # cached and hence not queried as part of call_single_thread). - (ModuleStoreEnum.Type.mongo, False, 1, 5, 2, 23, 8), - (ModuleStoreEnum.Type.mongo, False, 50, 5, 2, 23, 8), + (ModuleStoreEnum.Type.mongo, False, 1, 5, 2, 24, 9), + (ModuleStoreEnum.Type.mongo, False, 50, 5, 2, 24, 9), # split mongo: 3 queries, regardless of thread response size. - (ModuleStoreEnum.Type.split, False, 1, 3, 3, 23, 8), - (ModuleStoreEnum.Type.split, False, 50, 3, 3, 23, 8), + (ModuleStoreEnum.Type.split, False, 1, 3, 3, 24, 9), + (ModuleStoreEnum.Type.split, False, 50, 3, 3, 24, 9), # Enabling Enterprise integration should have no effect on the number of mongo queries made. - (ModuleStoreEnum.Type.mongo, True, 1, 5, 2, 23, 8), - (ModuleStoreEnum.Type.mongo, True, 50, 5, 2, 23, 8), + (ModuleStoreEnum.Type.mongo, True, 1, 5, 2, 24, 9), + (ModuleStoreEnum.Type.mongo, True, 50, 5, 2, 24, 9), # split mongo: 3 queries, regardless of thread response size. - (ModuleStoreEnum.Type.split, True, 1, 3, 3, 23, 8), - (ModuleStoreEnum.Type.split, True, 50, 3, 3, 23, 8), + (ModuleStoreEnum.Type.split, True, 1, 3, 3, 24, 9), + (ModuleStoreEnum.Type.split, True, 50, 3, 3, 24, 9), ) @ddt.unpack def test_number_of_mongo_queries( @@ -654,6 +686,22 @@ class SingleThreadAccessTestCase(CohortedTestCase): ) self.assertEqual(resp.status_code, 200) + def test_private_team_thread(self, mock_request): + CourseTeamFactory.create(discussion_topic_id='dummy_discussion_id') + user_not_in_team = UserFactory.create() + CourseEnrollmentFactory(user=user_not_in_team, course_id=self.course.id) + + with patch('lms.djangoapps.teams.api.is_team_discussion_private', autospec=True) as mocked: + mocked.return_value = True + response = self.call_view( + mock_request, + 'non_cohorted_topic', + user_not_in_team, + None + ) + self.assertEqual(403, response.status_code) + self.assertEqual(views.TEAM_PERMISSION_MESSAGE, response.content) + @patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) class SingleThreadGroupIdTestCase(CohortedTestCase, GroupIdAssertionMixin): @@ -912,6 +960,7 @@ class InlineDiscussionContextTestCase(ForumsEnableMixin, ModuleStoreTestCase): ) self.team.add_user(self.user) + self.user_not_in_team = UserFactory.create() def test_context_can_be_standalone(self, mock_request): mock_request.side_effect = make_mock_request_impl( @@ -932,6 +981,28 @@ class InlineDiscussionContextTestCase(ForumsEnableMixin, ModuleStoreTestCase): json_response = json.loads(response.content.decode('utf-8')) self.assertEqual(json_response['discussion_data'][0]['context'], ThreadContext.STANDALONE) + def test_private_team_discussion(self, mock_request): + # First set the team discussion to be private + CourseEnrollmentFactory(user=self.user_not_in_team, course_id=self.course.id) + request = RequestFactory().get("dummy_url") + request.user = self.user_not_in_team + + mock_request.side_effect = make_mock_request_impl( + course=self.course, + text="dummy text", + commentable_id=self.discussion_topic_id + ) + + with patch('lms.djangoapps.teams.api.is_team_discussion_private', autospec=True) as mocked: + mocked.return_value = True + response = views.inline_discussion( + request, + six.text_type(self.course.id), + self.discussion_topic_id, + ) + self.assertEqual(response.status_code, 403) + self.assertEqual(response.content.decode('utf-8'), views.TEAM_PERMISSION_MESSAGE) + @patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) class InlineDiscussionGroupIdTestCase( diff --git a/lms/djangoapps/discussion/views.py b/lms/djangoapps/discussion/views.py index 92838fcc10..5d43d4ade5 100644 --- a/lms/djangoapps/discussion/views.py +++ b/lms/djangoapps/discussion/views.py @@ -11,12 +11,13 @@ from django.conf import settings from django.contrib.auth.decorators import login_required from django.contrib.auth.models import User from django.contrib.staticfiles.storage import staticfiles_storage -from django.http import Http404, HttpResponseServerError +from django.http import Http404, HttpResponseForbidden, HttpResponseServerError from django.shortcuts import render_to_response from django.template.context_processors import csrf from django.template.loader import render_to_string from django.urls import reverse from django.utils.translation import get_language_bidi +from django.utils.translation import ugettext_lazy as _ from django.views.decorators.csrf import ensure_csrf_cookie from django.views.decorators.http import require_GET, require_http_methods from edx_django_utils.monitoring import function_trace @@ -31,7 +32,7 @@ from lms.djangoapps.courseware.courses import get_course_with_access from lms.djangoapps.courseware.views.views import CourseTabView from lms.djangoapps.discussion.django_comment_client.base.views import track_thread_viewed_event from lms.djangoapps.discussion.django_comment_client.constants import TYPE_ENTRY -from lms.djangoapps.discussion.django_comment_client.permissions import get_team, has_permission +from lms.djangoapps.discussion.django_comment_client.permissions import has_permission from lms.djangoapps.discussion.django_comment_client.utils import ( add_courseware_context, available_division_schemes, @@ -43,7 +44,9 @@ from lms.djangoapps.discussion.django_comment_client.utils import ( is_commentable_divided, strip_none ) +from lms.djangoapps.discussion.exceptions import TeamDiscussionHiddenFromUserException from lms.djangoapps.experiments.utils import get_experiment_user_metadata_context +from lms.djangoapps.teams import api as team_api from openedx.core.djangoapps.django_comment_common.models import CourseDiscussionSettings from openedx.core.djangoapps.django_comment_common.utils import ( ThreadContext, @@ -66,6 +69,7 @@ INLINE_THREADS_PER_PAGE = 20 PAGES_NEARBY_DELTA = 2 BOOTSTRAP_DISCUSSION_CSS_PATH = 'css/discussion/lms-discussion-bootstrap.css' +TEAM_PERMISSION_MESSAGE = _("Access to this discussion is restricted to team members and staff.") def make_course_settings(course, user, include_category_map=True): @@ -122,9 +126,11 @@ def get_threads(request, course, user_info, discussion_id=None, per_page=THREADS if discussion_id is not None: 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: + if team_api.get_team_by_discussion(discussion_id) is not None: default_query_params['context'] = ThreadContext.STANDALONE + _check_team_discussion_access(request, course, discussion_id) + if not request.GET.get('sort_key'): # If the user did not select a sort key, use their last used sort key default_query_params['sort_key'] = user_info.get('default_sort_key') or default_query_params['sort_key'] @@ -200,7 +206,6 @@ def inline_discussion(request, course_key, discussion_id): """ Renders JSON for DiscussionModules """ - with function_trace('get_course_and_user_info'): course = get_course_with_access(request.user, 'load', course_key, check_if_enrolled=True) cc_user = cc.User.from_django_user(request.user) @@ -213,6 +218,8 @@ def inline_discussion(request, course_key, discussion_id): ) except ValueError: return HttpResponseServerError('Invalid group_id') + except TeamDiscussionHiddenFromUserException: + return HttpResponseForbidden(TEAM_PERMISSION_MESSAGE) with function_trace('get_metadata_for_threads'): annotated_content_info = utils.get_metadata_for_threads(course_key, threads, request.user, user_info) @@ -300,10 +307,17 @@ def single_thread(request, course_key, discussion_id, thread_id): """ course = get_course_with_access(request.user, 'load', course_key, check_if_enrolled=True) request.user.is_community_ta = utils.is_user_community_ta(request.user, course.id) + if request.is_ajax(): cc_user = cc.User.from_django_user(request.user) user_info = cc_user.to_dict() is_staff = has_permission(request.user, 'openclose_thread', course.id) + + try: + _check_team_discussion_access(request, course, discussion_id) + except TeamDiscussionHiddenFromUserException: + return HttpResponseForbidden(TEAM_PERMISSION_MESSAGE) + thread = _load_thread_for_viewing( request, course, @@ -461,7 +475,7 @@ def _create_discussion_board_context(request, base_context, thread=None): cc_user = cc.User.from_django_user(user) user_info = context['user_info'] if thread: - + _check_team_discussion_access(request, course, discussion_id) # Since we're in page render mode, and the discussions UI will request the thread list itself, # we need only return the thread information for this one. threads = [thread.to_dict()] @@ -760,6 +774,20 @@ class DiscussionBoardFragmentView(EdxFragmentView): fragment = Fragment(html) self.add_fragment_resource_urls(fragment) return fragment + except TeamDiscussionHiddenFromUserException: + log.warning( + u'User with id={user_id} tried to view private discussion with id={discussion_id}'.format( + user_id=request.user.id, + discussion_id=discussion_id + ) + ) + html = render_to_string('discussion/discussion_private_fragment.html', { + 'disable_courseware_js': True, + 'uses_pattern_library': True, + }) + fragment = Fragment(html) + self.add_fragment_resource_urls(fragment) + return fragment def vendor_js_dependencies(self): """ @@ -963,3 +991,13 @@ def get_divided_discussions(course, discussion_settings): divided_inline_discussions.append(divided_discussion_id) return divided_course_wide_discussions, divided_inline_discussions + + +def _check_team_discussion_access(request, course, discussion_id): + """ + Helper function to check if the discussion is visible to the user, + if the user is on a team, which has the discussion set to private. + """ + user_is_course_staff = has_access(request.user, "staff", course) + if not user_is_course_staff and not team_api.discussion_visible_by_user(discussion_id, request.user): + raise TeamDiscussionHiddenFromUserException() diff --git a/lms/djangoapps/teams/api.py b/lms/djangoapps/teams/api.py new file mode 100644 index 0000000000..d7849b3abd --- /dev/null +++ b/lms/djangoapps/teams/api.py @@ -0,0 +1,52 @@ +""" +The Python API other app should use to work with Teams feature +""" +from __future__ import absolute_import + +from lms.djangoapps.teams.models import CourseTeam + + +def get_team_by_discussion(discussion_id): + """ + This is a function to get team object by the discussion_id passed in. + If the discussion_id is not associated with any team, we return None + """ + try: + return CourseTeam.objects.get(discussion_topic_id=discussion_id) + except CourseTeam.DoesNotExist: + # When the discussion does not belong to a team. It's visible in + # any team context + return None + + +def is_team_discussion_private(team): + """ + This is the function to check if the team is configured to have its discussion + to be private. We need a way to check the setting on the team. + This function also provide ways to toggle the setting of discussion visibility on the + individual team level. + To be followed up by MST-25 + """ + return getattr(team, 'is_discussion_private', False) + + +def user_is_a_team_member(user, team): + """ + Return if the user is a member of the team + If the team is not defined, return False + """ + if team: + return team.users.filter(id=user.id).exists() + return False + + +def discussion_visible_by_user(discussion_id, user): + """ + This function checks whether the discussion should be visible to the user. + The discussion should not be visible to the user if + * The discussion is part of the Team AND + * The team is configured to hide the discussions from non-teammembers AND + * The user is not part of the team + """ + team = get_team_by_discussion(discussion_id) + return not is_team_discussion_private(team) or user_is_a_team_member(user, team) diff --git a/lms/djangoapps/teams/static/teams/js/spec/views/team_discussion_spec.js b/lms/djangoapps/teams/static/teams/js/spec/views/team_discussion_spec.js index 9ce818673c..86dfb74222 100644 --- a/lms/djangoapps/teams/static/teams/js/spec/views/team_discussion_spec.js +++ b/lms/djangoapps/teams/static/teams/js/spec/views/team_discussion_spec.js @@ -6,8 +6,8 @@ define([ 'teams/js/views/team_discussion' ], function(_, AjaxHelpers, DiscussionSpecHelper, TeamSpecHelpers, TeamDiscussionView) { 'use strict'; - xdescribe('TeamDiscussionView', function() { - var discussionView, createDiscussionView, createPost, expandReplies, postReply; + describe('TeamDiscussionView', function() { + var discussionView, createDiscussionView; beforeEach(function() { setFixtures('
'); @@ -18,198 +18,47 @@ define([ DiscussionSpecHelper.setUnderscoreFixtures(); }); - createDiscussionView = function(requests, threads) { + createDiscussionView = function(requests, threads, errorStatus, errorBody) { discussionView = new TeamDiscussionView({ el: '.discussion-module' }); discussionView.render(); - AjaxHelpers.expectRequest( - requests, 'GET', - interpolate( - '/courses/%(courseID)s/discussion/forum/%(discussionID)s/inline?page=1&ajax=1', - { - courseID: TeamSpecHelpers.testCourseID, - discussionID: TeamSpecHelpers.testTeamDiscussionID - }, - true - - ) - ); - AjaxHelpers.respondWithJson(requests, TeamSpecHelpers.createMockDiscussionResponse(threads)); + if (errorStatus && errorBody) { + AjaxHelpers.respondWithError( + requests, + errorStatus, + errorBody + ); + } else { + AjaxHelpers.respondWithJson( + requests, + TeamSpecHelpers.createMockDiscussionResponse(threads) + ); + } return discussionView; }; - createPost = function(requests, view, title, body, threadID) { - title = title || 'Test title'; - body = body || 'Test body'; - threadID = threadID || '999'; - view.$('.new-post-button').click(); - view.$('.js-post-title').val(title); - view.$('.js-post-body textarea').val(body); - view.$('.submit').click(); - AjaxHelpers.expectRequest( - requests, 'POST', - interpolate( - '/courses/%(courseID)s/discussion/%(discussionID)s/threads/create?ajax=1', - { - courseID: TeamSpecHelpers.testCourseID, - discussionID: TeamSpecHelpers.testTeamDiscussionID - }, - true - ), - interpolate( - 'thread_type=discussion&title=%(title)s&body=%(body)s&anonymous=false&anonymous_to_peers=false&auto_subscribe=true', - { - title: title.replace(/ /g, '+'), - body: body.replace(/ /g, '+') - }, - true - ) - ); - AjaxHelpers.respondWithJson(requests, { - content: TeamSpecHelpers.createMockPostResponse({ - id: threadID, - title: title, - body: body - }), - annotated_content_info: TeamSpecHelpers.createAnnotatedContentInfo() - }); - }; - - expandReplies = function(requests, view, threadID) { - view.$('.forum-thread-expand').first().click(); - AjaxHelpers.expectRequest( - requests, 'GET', - interpolate( - '/courses/%(courseID)s/discussion/forum/%(discussionID)s/threads/%(threadID)s?ajax=1&resp_skip=0&resp_limit=25', - { - courseID: TeamSpecHelpers.testCourseID, - discussionID: TeamSpecHelpers.testTeamDiscussionID, - threadID: threadID || '999' - }, - true - ) - ); - AjaxHelpers.respondWithJson(requests, { - content: TeamSpecHelpers.createMockThreadResponse(), - annotated_content_info: TeamSpecHelpers.createAnnotatedContentInfo() - }); - }; - - postReply = function(requests, view, reply, threadID) { - var replyForm = view.$('.discussion-reply-new').first(); - replyForm.find('.reply-body textarea').val(reply); - replyForm.find('.discussion-submit-post').click(); - AjaxHelpers.expectRequest( - requests, 'POST', - interpolate( - '/courses/%(courseID)s/discussion/threads/%(threadID)s/reply?ajax=1', - { - courseID: TeamSpecHelpers.testCourseID, - threadID: threadID || '999' - }, - true - ), - 'body=' + reply.replace(/ /g, '+') - ); - AjaxHelpers.respondWithJson(requests, { - content: TeamSpecHelpers.createMockThreadResponse({ - body: reply, - comments_count: 1 - }), - annotated_content_info: TeamSpecHelpers.createAnnotatedContentInfo() - }); - }; - it('can render itself', function() { var requests = AjaxHelpers.requests(this), view = createDiscussionView(requests); - expect(view.$('.discussion-thread').length).toEqual(3); + expect(view.$('.forum-nav-thread-list .forum-nav-thread').length).toEqual(3); }); - it('can create a new post', function() { + it('cannot see discussion when user is not part of the team and discussion is set to be private', function() { var requests = AjaxHelpers.requests(this), - view = createDiscussionView(requests), - testTitle = 'New Post', - testBody = 'New post body', - newThreadElement; - createPost(requests, view, testTitle, testBody); - - // Expect the first thread to be the new post - expect(view.$('.discussion-thread').length).toEqual(4); - newThreadElement = view.$('.discussion-thread').first(); - expect(newThreadElement.find('.post-header-content h1').text().trim()).toEqual(testTitle); - expect(newThreadElement.find('.post-body').text().trim()).toEqual(testBody); - }); - - it('can post a reply', function() { - var requests = AjaxHelpers.requests(this), - view = createDiscussionView(requests), - testReply = 'Test reply', - testThreadID = '1'; - expandReplies(requests, view, testThreadID); - postReply(requests, view, testReply, testThreadID); - expect(view.$('.discussion-response .response-body').text().trim()).toBe(testReply); - }); - - it('can post a reply to a new post', function() { - var requests = AjaxHelpers.requests(this), - view = createDiscussionView(requests, []), - testReply = 'Test reply'; - createPost(requests, view); - expandReplies(requests, view); - postReply(requests, view, testReply); - expect(view.$('.discussion-response .response-body').text().trim()).toBe(testReply); - }); - - it('cannot move an existing thread to a different topic', function() { - var requests = AjaxHelpers.requests(this), - view = createDiscussionView(requests), - postTopicButton, updatedThreadElement, - updatedTitle = 'Updated title', - updatedBody = 'Updated body', - testThreadID = '1'; - expandReplies(requests, view, testThreadID); - view.$('.action-more .icon').first().click(); - view.$('.action-edit').first().click(); - postTopicButton = view.$('.post-topic'); - expect(postTopicButton.length).toBe(0); - view.$('.js-post-post-title').val(updatedTitle); - view.$('.js-post-body textarea').val(updatedBody); - view.$('.submit').click(); - AjaxHelpers.expectRequest( - requests, 'POST', - interpolate( - '/courses/%(courseID)s/discussion/%(discussionID)s/threads/create?ajax=1', - { - courseID: TeamSpecHelpers.testCourseID, - discussionID: TeamSpecHelpers.testTeamDiscussionID - }, - true - ), - 'thread_type=discussion&title=&body=Updated+body&anonymous=false&anonymous_to_peers=false&auto_subscribe=true' - ); - AjaxHelpers.respondWithJson(requests, { - content: TeamSpecHelpers.createMockPostResponse({ - id: '999', title: updatedTitle, body: updatedBody - }), - annotated_content_info: TeamSpecHelpers.createAnnotatedContentInfo() - }); - - // Expect the thread to have been updated - updatedThreadElement = view.$('.discussion-thread').first(); - expect(updatedThreadElement.find('.post-header-content h1').text().trim()).toEqual(updatedTitle); - expect(updatedThreadElement.find('.post-body').text().trim()).toEqual(updatedBody); - }); - - it('cannot move a new thread to a different topic', function() { - var requests = AjaxHelpers.requests(this), - view = createDiscussionView(requests); - createPost(requests, view); - expandReplies(requests, view); - view.$('.action-more .icon').first().click(); - view.$('.action-edit').first().click(); - expect(view.$('.post-topic').length).toBe(0); + errorMessage = 'Access to this thread is restricted to team members and staff.', + view = createDiscussionView( + requests, + [], + 403, + errorMessage + ); + expect(view.$el.text().trim().replace(/"/g, '')).toEqual(errorMessage); + expect($('.discussion-alert-wrapper p') + .text() + .trim() + .replace(/"/g, '') + ).toEqual(errorMessage); }); }); }); diff --git a/lms/djangoapps/teams/static/teams/js/spec_helpers/team_spec_helpers.js b/lms/djangoapps/teams/static/teams/js/spec_helpers/team_spec_helpers.js index da9a4e06cb..3b393f2266 100644 --- a/lms/djangoapps/teams/static/teams/js/spec_helpers/team_spec_helpers.js +++ b/lms/djangoapps/teams/static/teams/js/spec_helpers/team_spec_helpers.js @@ -143,8 +143,9 @@ define([ }; createMockDiscussionResponse = function(threads) { + var responseThreads = threads; if (_.isUndefined(threads)) { - threads = [ + responseThreads = [ createMockPostResponse({id: '1', title: 'First Post'}), createMockPostResponse({id: '2', title: 'Second Post'}), createMockPostResponse({id: '3', title: 'Third Post'}) @@ -153,7 +154,7 @@ define([ return { num_pages: 1, page: 1, - discussion_data: threads, + discussion_data: responseThreads, user_info: { username: testUser, follower_ids: [], diff --git a/lms/djangoapps/teams/tests/test_api.py b/lms/djangoapps/teams/tests/test_api.py new file mode 100644 index 0000000000..1de7f31678 --- /dev/null +++ b/lms/djangoapps/teams/tests/test_api.py @@ -0,0 +1,78 @@ +# -*- coding: utf-8 -*- +""" +Tests for Python APIs of the Teams app +""" +from __future__ import absolute_import + +from uuid import uuid4 +import unittest + +from opaque_keys.edx.keys import CourseKey + +from lms.djangoapps.teams import api as teams_api +from lms.djangoapps.teams.tests.factories import CourseTeamFactory +from student.tests.factories import CourseEnrollmentFactory, UserFactory +from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase + +COURSE_KEY1 = CourseKey.from_string('edx/history/1') +COURSE_KEY2 = CourseKey.from_string('edx/history/2') + +DISCUSSION_TOPIC_ID = uuid4().hex + + +class PythonAPITests(SharedModuleStoreTestCase): + """ + The set of tests for different API endpoints + """ + @classmethod + def setUpClass(cls): + super(PythonAPITests, cls).setUpClass() + cls.user1 = UserFactory.create(username='user1') + cls.user2 = UserFactory.create(username='user2') + cls.user3 = UserFactory.create(username='user3') + + for user in (cls.user1, cls.user2, cls.user3): + CourseEnrollmentFactory.create(user=user, course_id=COURSE_KEY1) + + CourseEnrollmentFactory.create(user=cls.user3, course_id=COURSE_KEY2) + + cls.team1 = CourseTeamFactory( + course_id=COURSE_KEY1, + discussion_topic_id=DISCUSSION_TOPIC_ID, + team_id='team1' + ) + cls.team2 = CourseTeamFactory(course_id=COURSE_KEY2, team_id='team2') + + cls.team1.add_user(cls.user1) + cls.team1.add_user(cls.user2) + cls.team2.add_user(cls.user3) + + def test_get_team_by_discussion_non_existence(self): + self.assertIsNone(teams_api.get_team_by_discussion('DO_NOT_EXIST')) + + def test_get_team_by_discussion_exists(self): + team = teams_api.get_team_by_discussion(DISCUSSION_TOPIC_ID) + self.assertEqual(team, self.team1) + + @unittest.skip("This functionality is not yet implemented") + def test_is_team_discussion_private_is_private(self): + self.assertTrue(teams_api.is_team_discussion_private(self.team1)) + + def test_is_team_discussion_private_is_public(self): + self.assertFalse(teams_api.is_team_discussion_private(None)) + self.assertFalse(teams_api.is_team_discussion_private(self.team2)) + + def test_user_is_a_team_member(self): + self.assertTrue(teams_api.user_is_a_team_member(self.user1, self.team1)) + self.assertFalse(teams_api.user_is_a_team_member(self.user1, None)) + self.assertFalse(teams_api.user_is_a_team_member(self.user1, self.team2)) + + def test_private_discussion_visible_by_user(self): + self.assertTrue(teams_api.discussion_visible_by_user(DISCUSSION_TOPIC_ID, self.user1)) + self.assertTrue(teams_api.discussion_visible_by_user(DISCUSSION_TOPIC_ID, self.user2)) + # self.assertFalse(teams_api.discussion_visible_by_user(DISCUSSION_TOPIC_ID, self.user3)) + + def test_public_discussion_visible_by_user(self): + self.assertTrue(teams_api.discussion_visible_by_user(self.team2.discussion_topic_id, self.user1)) + self.assertTrue(teams_api.discussion_visible_by_user(self.team2.discussion_topic_id, self.user2)) + self.assertTrue(teams_api.discussion_visible_by_user('DO_NOT_EXISTS', self.user3))