diff --git a/lms/djangoapps/discussion_api/api.py b/lms/djangoapps/discussion_api/api.py index a95e9bdb3b..bc918c8be0 100644 --- a/lms/djangoapps/discussion_api/api.py +++ b/lms/djangoapps/discussion_api/api.py @@ -39,7 +39,7 @@ from django_comment_common.signals import ( comment_created, comment_edited, comment_voted, - comment_deleted + comment_deleted, ) from django_comment_client.utils import get_accessible_discussion_modules, is_commentable_cohorted from lms.lib.comment_client.comment import Comment @@ -697,6 +697,23 @@ def update_comment(request, comment_id, update_data): return api_comment +def get_thread(request, thread_id): + """ + Retrieve a thread. + + Arguments: + + request: The django request object used for build_absolute_uri and + determining the requesting user. + + thread_id: The id for the thread to retrieve + + """ + cc_thread, context = _get_thread_and_context(request, thread_id) + serializer = ThreadSerializer(cc_thread, context=context) + return serializer.data + + def delete_thread(request, thread_id): """ Delete a thread. diff --git a/lms/djangoapps/discussion_api/tests/test_api.py b/lms/djangoapps/discussion_api/tests/test_api.py index 80b45d5183..525e19cdba 100644 --- a/lms/djangoapps/discussion_api/tests/test_api.py +++ b/lms/djangoapps/discussion_api/tests/test_api.py @@ -33,6 +33,7 @@ from discussion_api.api import ( get_thread_list, update_comment, update_thread, + get_thread, ) from discussion_api.tests.utils import ( CommentsServiceMockMixin, @@ -2786,3 +2787,173 @@ class DeleteCommentTest( self.assertFalse(expected_error) except Http404: self.assertTrue(expected_error) + + +@ddt.ddt +class RetrieveThreadTest( + CommentsServiceMockMixin, + UrlResetMixin, + SharedModuleStoreTestCase +): + """Tests for get_thread""" + @classmethod + @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) + def setUpClass(cls): + super(RetrieveThreadTest, cls).setUpClass() + cls.course = CourseFactory.create() + + @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) + def setUp(self): + super(RetrieveThreadTest, self).setUp() + httpretty.reset() + httpretty.enable() + self.addCleanup(httpretty.disable) + self.thread_author = UserFactory.create() + self.register_get_user_response(self.thread_author) + self.request = RequestFactory().get("/test_path") + self.request.user = self.thread_author + self.thread_id = "test_thread" + CourseEnrollmentFactory.create(user=self.thread_author, course_id=self.course.id) + + def register_thread(self, overrides=None): + """ + Make a thread with appropriate data overridden by the overrides + parameter and register mock responses for GET on its + endpoint. + """ + cs_data = make_minimal_cs_thread({ + "id": self.thread_id, + "course_id": unicode(self.course.id), + "commentable_id": "test_topic", + "username": self.thread_author.username, + "user_id": str(self.thread_author.id), + "title": "Test Title", + "body": "Test body", + "created_at": "2015-05-29T00:00:00Z", + "updated_at": "2015-05-29T00:00:00Z" + }) + cs_data.update(overrides or {}) + self.register_get_thread_response(cs_data) + + def test_basic(self): + expected_response_data = { + "author": self.thread_author.username, + "author_label": None, + "created_at": "2015-05-29T00:00:00Z", + "updated_at": "2015-05-29T00:00:00Z", + "raw_body": "Test body", + "rendered_body": "
Test body
", + "abuse_flagged": False, + "voted": False, + "vote_count": 0, + "editable_fields": ["abuse_flagged", "following", "raw_body", "title", "topic_id", "type", "voted"], + "course_id": unicode(self.course.id), + "topic_id": "test_topic", + "group_id": None, + "group_name": None, + "title": "Test Title", + "pinned": False, + "closed": False, + "following": False, + "comment_count": 0, + "unread_comment_count": 0, + "comment_list_url": "http://testserver/api/discussion/v1/comments/?thread_id=test_thread", + "endorsed_comment_list_url": None, + "non_endorsed_comment_list_url": None, + "read": False, + "has_endorsed": False, + "id": "test_thread", + "type": "discussion" + } + self.register_thread() + self.assertEqual(get_thread(self.request, self.thread_id), expected_response_data) + self.assertEqual(httpretty.last_request().method, "GET") + + def test_thread_id_not_found(self): + self.register_get_thread_error_response("missing_thread", 404) + with self.assertRaises(Http404): + get_thread(self.request, "missing_thread") + + def test_nonauthor_enrolled_in_course(self): + expected_response_data = { + "author": self.thread_author.username, + "author_label": None, + "created_at": "2015-05-29T00:00:00Z", + "updated_at": "2015-05-29T00:00:00Z", + "raw_body": "Test body", + "rendered_body": "Test body
", + "abuse_flagged": False, + "voted": False, + "vote_count": 0, + "editable_fields": ["abuse_flagged", "following", "voted"], + "course_id": unicode(self.course.id), + "topic_id": "test_topic", + "group_id": None, + "group_name": None, + "title": "Test Title", + "pinned": False, + "closed": False, + "following": False, + "comment_count": 0, + "unread_comment_count": 0, + "comment_list_url": "http://testserver/api/discussion/v1/comments/?thread_id=test_thread", + "endorsed_comment_list_url": None, + "non_endorsed_comment_list_url": None, + "read": False, + "has_endorsed": False, + "id": "test_thread", + "type": "discussion" + } + non_author_user = UserFactory.create() # pylint: disable=attribute-defined-outside-init + self.register_get_user_response(non_author_user) + CourseEnrollmentFactory.create(user=non_author_user, course_id=self.course.id) + self.register_thread() + self.request.user = non_author_user + self.assertEqual(get_thread(self.request, self.thread_id), expected_response_data) + self.assertEqual(httpretty.last_request().method, "GET") + + @ddt.data( + *itertools.product( + [ + FORUM_ROLE_ADMINISTRATOR, + FORUM_ROLE_MODERATOR, + FORUM_ROLE_COMMUNITY_TA, + FORUM_ROLE_STUDENT, + ], + [True, False], + ["no_group", "match_group", "different_group"], + ) + ) + @ddt.unpack + def test_group_access(self, role_name, course_is_cohorted, thread_group_state): + """ + Tests group access for retrieving a thread + + All privileged roles are able to retrieve a thread. A student role can + only retrieve a thread if, + the student role is the author and the thread is not in a cohort, + the student role is the author and the thread is in the author's cohort. + """ + cohort_course = CourseFactory.create(cohort_config={"cohorted": course_is_cohorted}) + CourseEnrollmentFactory.create(user=self.thread_author, course_id=cohort_course.id) + cohort = CohortFactory.create(course_id=cohort_course.id, users=[self.thread_author]) + role = Role.objects.create(name=role_name, course_id=cohort_course.id) + role.users = [self.thread_author] + self.register_thread({ + "course_id": unicode(cohort_course.id), + "group_id": ( + None if thread_group_state == "no_group" else + cohort.id if thread_group_state == "match_group" else + cohort.id + 1 + ), + }) + expected_error = ( + role_name == FORUM_ROLE_STUDENT and + course_is_cohorted and + thread_group_state == "different_group" + ) + try: + get_thread(self.request, self.thread_id) + self.assertFalse(expected_error) + except Http404: + self.assertTrue(expected_error) diff --git a/lms/djangoapps/discussion_api/tests/test_views.py b/lms/djangoapps/discussion_api/tests/test_views.py index 23abc7adba..3046233734 100644 --- a/lms/djangoapps/discussion_api/tests/test_views.py +++ b/lms/djangoapps/discussion_api/tests/test_views.py @@ -588,6 +588,7 @@ class ThreadViewSetDeleteTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase): self.thread_id = "test_thread" def test_basic(self): + #from nose.tools import set_trace;set_trace() self.register_get_user_response(self.user) cs_thread = make_minimal_cs_thread({ "id": self.thread_id, @@ -607,6 +608,7 @@ class ThreadViewSetDeleteTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase): self.assertEqual(httpretty.last_request().method, "DELETE") def test_delete_nonexistent_thread(self): + #from nose.tools import set_trace;set_trace() self.register_get_thread_error_response(self.thread_id, 404) response = self.client.delete(self.url) self.assertEqual(response.status_code, 404) @@ -951,3 +953,65 @@ class CommentViewSetPartialUpdateTest(DiscussionAPIViewTestMixin, ModuleStoreTes self.assertEqual(response.status_code, 400) response_data = json.loads(response.content) self.assertEqual(response_data, expected_response_data) + + +@httpretty.activate +class ThreadViewSetRetrieveTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase): + """Tests for ThreadViewSet Retrieve""" + def setUp(self): + super(ThreadViewSetRetrieveTest, self).setUp() + self.url = reverse("thread-detail", kwargs={"thread_id": "test_thread"}) + self.thread_id = "test_thread" + + def test_basic(self): + self.register_get_user_response(self.user) + cs_thread = make_minimal_cs_thread({ + "id": self.thread_id, + "course_id": unicode(self.course.id), + "commentable_id": "test_topic", + "username": self.user.username, + "user_id": str(self.user.id), + "title": "Test Title", + "body": "Test body", + "created_at": "2015-05-29T00:00:00Z", + "updated_at": "2015-05-29T00:00:00Z" + }) + expected_response_data = { + "author": self.user.username, + "author_label": None, + "created_at": "2015-05-29T00:00:00Z", + "updated_at": "2015-05-29T00:00:00Z", + "raw_body": "Test body", + "rendered_body": "Test body
", + "abuse_flagged": False, + "voted": False, + "vote_count": 0, + "editable_fields": ["abuse_flagged", "following", "raw_body", "title", "topic_id", "type", "voted"], + "course_id": unicode(self.course.id), + "topic_id": "test_topic", + "group_id": None, + "group_name": None, + "title": "Test Title", + "pinned": False, + "closed": False, + "following": False, + "comment_count": 0, + "unread_comment_count": 0, + "comment_list_url": "http://testserver/api/discussion/v1/comments/?thread_id=test_thread", + "endorsed_comment_list_url": None, + "non_endorsed_comment_list_url": None, + "read": False, + "has_endorsed": False, + "id": "test_thread", + "type": "discussion" + } + self.register_get_thread_response(cs_thread) + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + self.assertEqual(json.loads(response.content), expected_response_data) + self.assertEqual(httpretty.last_request().method, "GET") + + def test_retrieve_nonexistent_thread(self): + self.register_get_thread_error_response(self.thread_id, 404) + response = self.client.get(self.url) + self.assertEqual(response.status_code, 404) diff --git a/lms/djangoapps/discussion_api/views.py b/lms/djangoapps/discussion_api/views.py index 38bc5ee784..5881c63444 100644 --- a/lms/djangoapps/discussion_api/views.py +++ b/lms/djangoapps/discussion_api/views.py @@ -22,7 +22,7 @@ from discussion_api.api import ( get_thread_list, update_comment, update_thread, -) + get_thread) from discussion_api.forms import CommentListGetForm, ThreadListGetForm from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin @@ -257,6 +257,12 @@ class ThreadViewSet(_ViewMixin, DeveloperErrorViewMixin, ViewSet): ) ) + def retrieve(self, request, thread_id=None): + """ + Implements the GET method for thread ID + """ + return Response(get_thread(request, thread_id)) + def create(self, request): """ Implements the POST method for the list endpoint as described in the diff --git a/lms/djangoapps/django_comment_client/base/views.py b/lms/djangoapps/django_comment_client/base/views.py index 84b494eb37..9c33cfa450 100644 --- a/lms/djangoapps/django_comment_client/base/views.py +++ b/lms/djangoapps/django_comment_client/base/views.py @@ -27,7 +27,7 @@ from django_comment_common.signals import ( comment_edited, comment_voted, comment_deleted, - comment_endorsed + comment_endorsed, ) from django_comment_common.utils import ThreadContext from django_comment_client.utils import (