diff --git a/lms/djangoapps/discussion_api/api.py b/lms/djangoapps/discussion_api/api.py index f4df4a79745c931fe761ca383e9363472c1038a1..597f08fd80b4c76a8e623a0c8f3ca97f2a2848f5 100644 --- a/lms/djangoapps/discussion_api/api.py +++ b/lms/djangoapps/discussion_api/api.py @@ -1,9 +1,14 @@ """ Discussion API internal interface """ +from urllib import urlencode +from urlparse import urlunparse + from django.core.exceptions import ValidationError +from django.core.urlresolvers import reverse from django.http import Http404 + from collections import defaultdict from opaque_keys import InvalidKeyError @@ -38,7 +43,16 @@ def _get_course_or_404(course_key, user): return course -def get_course_topics(course_key, user): +def get_thread_list_url(request, course_key, topic_id_list): + """ + Returns the URL for the thread_list_url field, given a list of topic_ids + """ + path = reverse("thread-list") + query_list = [("course_id", unicode(course_key))] + [("topic_id", topic_id) for topic_id in topic_id_list] + return request.build_absolute_uri(urlunparse(("", "", path, "", urlencode(query_list), ""))) + + +def get_course_topics(request, course_key): """ Return the course topic listing for the given course and user. @@ -59,22 +73,33 @@ def get_course_topics(course_key, user): """ return module.sort_key or module.discussion_target - course = _get_course_or_404(course_key, user) - discussion_modules = get_accessible_discussion_modules(course, user) + course = _get_course_or_404(course_key, request.user) + discussion_modules = get_accessible_discussion_modules(course, request.user) modules_by_category = defaultdict(list) for module in discussion_modules: modules_by_category[module.discussion_category].append(module) + + def get_sorted_modules(category): + """Returns key sorted modules by category""" + return sorted(modules_by_category[category], key=get_module_sort_key) + courseware_topics = [ { "id": None, "name": category, + "thread_list_url": get_thread_list_url( + request, + course_key, + [item.discussion_id for item in get_sorted_modules(category)] + ), "children": [ { "id": module.discussion_id, "name": module.discussion_target, + "thread_list_url": get_thread_list_url(request, course_key, [module.discussion_id]), "children": [], } - for module in sorted(modules_by_category[category], key=get_module_sort_key) + for module in get_sorted_modules(category) ], } for category in sorted(modules_by_category.keys()) @@ -84,6 +109,7 @@ def get_course_topics(course_key, user): { "id": entry["id"], "name": name, + "thread_list_url": get_thread_list_url(request, course_key, [entry["id"]]), "children": [], } for name, entry in sorted( @@ -98,7 +124,7 @@ def get_course_topics(course_key, user): } -def get_thread_list(request, course_key, page, page_size): +def get_thread_list(request, course_key, page, page_size, topic_id_list=None): """ Return the list of all discussion threads pertaining to the given course @@ -108,6 +134,7 @@ def get_thread_list(request, course_key, page, page_size): course_key: The key of the course to get discussion threads for page: The page number (1-indexed) to retrieve page_size: The number of threads to retrieve per page + topic_id_list: The list of topic_ids to get the discussion threads for Returns: @@ -116,6 +143,7 @@ def get_thread_list(request, course_key, page, page_size): """ course = _get_course_or_404(course_key, request.user) context = get_context(course, request) + topic_ids_csv = ",".join(topic_id_list) if topic_id_list else None threads, result_page, num_pages, _ = Thread.search({ "course_id": unicode(course.id), "group_id": ( @@ -126,6 +154,7 @@ def get_thread_list(request, course_key, page, page_size): "sort_order": "desc", "page": page, "per_page": page_size, + "commentable_ids": topic_ids_csv, }) # The comments service returns the last page of results if the requested # page is beyond the last page, but we want be consistent with DRF's general diff --git a/lms/djangoapps/discussion_api/forms.py b/lms/djangoapps/discussion_api/forms.py index 08dd42330a1b3a30a36405b2ba1ff2cc09dcc961..2ae045c0c5388ac261d2c2d0925d034f3da9c78b 100644 --- a/lms/djangoapps/discussion_api/forms.py +++ b/lms/djangoapps/discussion_api/forms.py @@ -2,12 +2,31 @@ Discussion API forms """ from django.core.exceptions import ValidationError -from django.forms import BooleanField, CharField, Form, IntegerField, NullBooleanField +from django.forms import ( + BooleanField, + CharField, + Field, + Form, + IntegerField, + MultipleHiddenInput, + NullBooleanField, +) from opaque_keys import InvalidKeyError from opaque_keys.edx.locator import CourseLocator +class TopicIdField(Field): + """ + Field for a list of topic_ids + """ + widget = MultipleHiddenInput + + def validate(self, value): + if value and "" in value: + raise ValidationError("This field cannot be empty.") + + class _PaginationForm(Form): """A form that includes pagination fields""" page = IntegerField(required=False, min_value=1) @@ -27,6 +46,7 @@ class ThreadListGetForm(_PaginationForm): A form to validate query parameters in the thread list retrieval endpoint """ course_id = CharField() + topic_id = TopicIdField(required=False) def clean_course_id(self): """Validate course_id""" diff --git a/lms/djangoapps/discussion_api/tests/test_api.py b/lms/djangoapps/discussion_api/tests/test_api.py index 296070cf3ae8ac695470cb7937677b78d55efb5d..96843a70f544b5dd2d979012065d9296eaea8524 100644 --- a/lms/djangoapps/discussion_api/tests/test_api.py +++ b/lms/djangoapps/discussion_api/tests/test_api.py @@ -3,7 +3,8 @@ Tests for Discussion API internal interface """ from datetime import datetime, timedelta import itertools -from urlparse import urlparse +from urlparse import urlparse, urlunparse +from urllib import urlencode import ddt import httpretty @@ -57,9 +58,10 @@ def _remove_discussion_tab(course, user_id): @mock.patch.dict("django.conf.settings.FEATURES", {"DISABLE_START_DATES": False}) -class GetCourseTopicsTest(ModuleStoreTestCase): +class GetCourseTopicsTest(UrlResetMixin, ModuleStoreTestCase): """Test for get_course_topics""" + @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) def setUp(self): super(GetCourseTopicsTest, self).setUp() self.maxDiff = None # pylint: disable=invalid-name @@ -81,6 +83,8 @@ class GetCourseTopicsTest(ModuleStoreTestCase): days_early_for_beta=3 ) self.user = UserFactory.create() + self.request = RequestFactory().get("/dummy") + self.request.user = self.user CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id) def make_discussion_module(self, topic_id, category, subcategory, **kwargs): @@ -94,34 +98,46 @@ class GetCourseTopicsTest(ModuleStoreTestCase): **kwargs ) - def get_course_topics(self, user=None): + def get_thread_list_url(self, topic_id_list): + """ + Returns the URL for the thread_list_url field, given a list of topic_ids + """ + path = "http://testserver/api/discussion/v1/threads/" + query_list = [("course_id", unicode(self.course.id))] + [("topic_id", topic_id) for topic_id in topic_id_list] + return urlunparse(("", "", path, "", urlencode(query_list), "")) + + def get_course_topics(self): """ Get course topics for self.course, using the given user or self.user if not provided, and generating absolute URIs with a test scheme/host. """ - return get_course_topics(self.course.id, user or self.user) + return get_course_topics(self.request, self.course.id) def make_expected_tree(self, topic_id, name, children=None): """ Build an expected result tree given a topic id, display name, and children """ + topic_id_list = [topic_id] if topic_id else [child["id"] for child in children] children = children or [] node = { "id": topic_id, "name": name, "children": children, + "thread_list_url": self.get_thread_list_url(topic_id_list) } + return node def test_nonexistent_course(self): with self.assertRaises(Http404): - get_course_topics(CourseLocator.from_string("non/existent/course"), self.user) + get_course_topics(self.request, CourseLocator.from_string("non/existent/course")) def test_not_enrolled(self): unenrolled_user = UserFactory.create() + self.request.user = unenrolled_user with self.assertRaises(Http404): - get_course_topics(self.course.id, unenrolled_user) + self.get_course_topics() def test_discussions_disabled(self): _remove_discussion_tab(self.course, self.user.id) @@ -310,8 +326,8 @@ class GetCourseTopicsTest(ModuleStoreTestCase): ], } self.assertEqual(student_actual, student_expected) - - beta_actual = self.get_course_topics(beta_tester) + self.request.user = beta_tester + beta_actual = self.get_course_topics() beta_expected = { "courseware_topics": [ self.make_expected_tree( @@ -334,7 +350,8 @@ class GetCourseTopicsTest(ModuleStoreTestCase): } self.assertEqual(beta_actual, beta_expected) - staff_actual = self.get_course_topics(staff) + self.request.user = staff + staff_actual = self.get_course_topics() staff_expected = { "courseware_topics": [ self.make_expected_tree( @@ -381,14 +398,14 @@ class GetThreadListTest(CommentsServiceMockMixin, UrlResetMixin, ModuleStoreTest self.author = UserFactory.create() self.cohort = CohortFactory.create(course_id=self.course.id) - def get_thread_list(self, threads, page=1, page_size=1, num_pages=1, course=None): + def get_thread_list(self, threads, page=1, page_size=1, num_pages=1, course=None, topic_id_list=None): """ Register the appropriate comments service response, then call get_thread_list and return the result. """ course = course or self.course self.register_get_threads_response(threads, page, num_pages) - ret = get_thread_list(self.request, course.id, page, page_size) + ret = get_thread_list(self.request, course.id, page, page_size, topic_id_list) return ret def test_nonexistent_course(self): @@ -415,6 +432,19 @@ class GetThreadListTest(CommentsServiceMockMixin, UrlResetMixin, ModuleStoreTest } ) + def test_get_threads_by_topic_id(self): + self.get_thread_list([], topic_id_list=["topic_x", "topic_meow"]) + self.assertEqual(urlparse(httpretty.last_request().path).path, "/api/v1/threads") + self.assert_last_query_params({ + "course_id": [unicode(self.course.id)], + "sort_key": ["date"], + "sort_order": ["desc"], + "page": ["1"], + "per_page": ["1"], + "recursive": ["False"], + "commentable_ids": ["topic_x,topic_meow"] + }) + def test_basic_query_params(self): self.get_thread_list([], page=6, page_size=14) self.assert_last_query_params({ diff --git a/lms/djangoapps/discussion_api/tests/test_forms.py b/lms/djangoapps/discussion_api/tests/test_forms.py index 584314d0cc335ce25ab137b2265fa5b7bba98d5d..07c712831135906efe1b334223ffc188dc317c16 100644 --- a/lms/djangoapps/discussion_api/tests/test_forms.py +++ b/lms/djangoapps/discussion_api/tests/test_forms.py @@ -2,6 +2,9 @@ Tests for Discussion API forms """ from unittest import TestCase +from urllib import urlencode + +from django.http import QueryDict from opaque_keys.edx.locator import CourseLocator @@ -66,13 +69,19 @@ class ThreadListGetFormTest(FormTestMixin, PaginationTestMixin, TestCase): def setUp(self): super(ThreadListGetFormTest, self).setUp() - self.form_data = { - "course_id": "Foo/Bar/Baz", - "page": "2", - "page_size": "13", - } + self.form_data = QueryDict( + urlencode( + { + "course_id": "Foo/Bar/Baz", + "page": "2", + "page_size": "13", + } + ), + mutable=True + ) def test_basic(self): + self.form_data.setlist("topic_id", ["example topic_id", "example 2nd topic_id"]) form = self.get_form(expected_valid=True) self.assertEqual( form.cleaned_data, @@ -80,6 +89,7 @@ class ThreadListGetFormTest(FormTestMixin, PaginationTestMixin, TestCase): "course_id": CourseLocator.from_string("Foo/Bar/Baz"), "page": 2, "page_size": 13, + "topic_id": ["example topic_id", "example 2nd topic_id"], } ) @@ -91,6 +101,10 @@ class ThreadListGetFormTest(FormTestMixin, PaginationTestMixin, TestCase): self.form_data["course_id"] = "invalid course id" self.assert_error("course_id", "'invalid course id' is not a valid course id") + def test_empty_topic_id(self): + self.form_data.setlist("topic_id", ["", "not empty"]) + self.assert_error("topic_id", "This field cannot be empty.") + class CommentListGetFormTest(FormTestMixin, PaginationTestMixin, TestCase): """Tests for CommentListGetForm""" diff --git a/lms/djangoapps/discussion_api/tests/test_views.py b/lms/djangoapps/discussion_api/tests/test_views.py index 545f8165ddc8b5f20095a61236af5b92fa3fa1e0..4bdc87193e8f8a4fdd40d0aed0048151b6973402 100644 --- a/lms/djangoapps/discussion_api/tests/test_views.py +++ b/lms/djangoapps/discussion_api/tests/test_views.py @@ -85,7 +85,9 @@ class CourseTopicsViewTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase): "non_courseware_topics": [{ "id": "test_topic", "name": "Test Topic", - "children": [] + "children": [], + "thread_list_url": + "http://testserver/api/discussion/v1/threads/?course_id=x%2Fy%2Fz&topic_id=test_topic", }], } ) diff --git a/lms/djangoapps/discussion_api/views.py b/lms/djangoapps/discussion_api/views.py index 96ce59ec06351463a8c838dbb5db25b9a5bcb06f..a1978f0aa529c7fe0339eff7e722d897add9fe3c 100644 --- a/lms/djangoapps/discussion_api/views.py +++ b/lms/djangoapps/discussion_api/views.py @@ -60,7 +60,7 @@ class CourseTopicsView(_ViewMixin, DeveloperErrorViewMixin, APIView): def get(self, request, course_id): """Implements the GET method as described in the class docstring.""" course_key = CourseLocator.from_string(course_id) - return Response(get_course_topics(course_key, request.user)) + return Response(get_course_topics(request, course_key)) class ThreadViewSet(_ViewMixin, DeveloperErrorViewMixin, ViewSet): @@ -90,6 +90,10 @@ class ThreadViewSet(_ViewMixin, DeveloperErrorViewMixin, ViewSet): * page_size: The number of items per page (default is 10, max is 100) + * topic_id: The id of the topic to retrieve the threads. There can be + multiple topic_id queries to retrieve threads from multiple topics + at once. + **POST Parameters**: * course_id (required): The course to create the thread in @@ -157,7 +161,8 @@ class ThreadViewSet(_ViewMixin, DeveloperErrorViewMixin, ViewSet): request, form.cleaned_data["course_id"], form.cleaned_data["page"], - form.cleaned_data["page_size"] + form.cleaned_data["page_size"], + form.cleaned_data["topic_id"], ) )