diff --git a/lms/djangoapps/discussion/rest_api/tests/test_views.py b/lms/djangoapps/discussion/rest_api/tests/test_views.py index 66b3e345bd..837be34928 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_views.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_views.py @@ -32,7 +32,12 @@ from common.djangoapps.course_modes.models import CourseMode from common.djangoapps.course_modes.tests.factories import CourseModeFactory from common.djangoapps.student.models import get_retired_username_by_username, CourseEnrollment from common.djangoapps.student.roles import CourseInstructorRole, CourseStaffRole, GlobalStaff -from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, SuperuserFactory, UserFactory +from common.djangoapps.student.tests.factories import ( + AdminFactory, + CourseEnrollmentFactory, + SuperuserFactory, + UserFactory +) from common.djangoapps.util.testing import PatchMediaTypeMixin, UrlResetMixin from common.test.utils import disable_signal from lms.djangoapps.discussion.django_comment_client.tests.utils import ( @@ -50,6 +55,9 @@ from lms.djangoapps.discussion.rest_api.tests.utils import ( parsed_body, ) from openedx.core.djangoapps.course_groups.tests.helpers import config_course_cohorts +from openedx.core.djangoapps.discussions.config.waffle import ENABLE_NEW_STRUCTURE_DISCUSSIONS +from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration, DiscussionTopicLink, Provider +from openedx.core.djangoapps.discussions.tasks import update_discussions_settings_from_course_task from openedx.core.djangoapps.django_comment_common.models import CourseDiscussionSettings, Role from openedx.core.djangoapps.django_comment_common.utils import seed_permissions_roles from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_for_user @@ -869,6 +877,97 @@ class CourseTopicsViewTest(DiscussionAPIViewTestMixin, CommentsServiceMockMixin, ) +@ddt.ddt +@mock.patch('lms.djangoapps.discussion.rest_api.api._get_course', mock.Mock()) +@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) +@override_waffle_flag(ENABLE_NEW_STRUCTURE_DISCUSSIONS, True) +class CourseTopicsViewV3Test(DiscussionAPIViewTestMixin, CommentsServiceMockMixin, ModuleStoreTestCase): + """ + Tests for CourseTopicsViewV3 + """ + def setUp(self) -> None: + super().setUp() + self.password = "password" + self.user = UserFactory.create(password=self.password) + self.client.login(username=self.user.username, password=self.password) + self.staff = AdminFactory.create() + self.course = CourseFactory.create( + start=datetime(2020, 1, 1), + end=datetime(2028, 1, 1), + enrollment_start=datetime(2020, 1, 1), + enrollment_end=datetime(2028, 1, 1), + discussion_topics={"Course Wide Topic": { + "id": 'course-wide-topic', + "usage_key": None, + }} + ) + self.chapter = ItemFactory.create( + parent_location=self.course.location, + category='chapter', + display_name="Week 1", + start=datetime(2015, 3, 1, tzinfo=UTC), + ) + self.sequential = ItemFactory.create( + parent_location=self.chapter.location, + category='sequential', + display_name="Lesson 1", + start=datetime(2015, 3, 1, tzinfo=UTC), + ) + self.verticals = [ + ItemFactory.create( + parent_location=self.sequential.location, + category='vertical', + display_name='vertical', + start=datetime(2015, 4, 1, tzinfo=UTC), + ) + ] + course_key = self.course.id + self.config = DiscussionsConfiguration.objects.create(context_key=course_key, provider_type=Provider.OPEN_EDX) + topic_links = [] + update_discussions_settings_from_course_task(str(course_key)) + topic_id_query = DiscussionTopicLink.objects.filter(context_key=course_key).values_list( + 'external_id', flat=True, + ) + topic_ids = list(topic_id_query.order_by('ordering')) + DiscussionTopicLink.objects.bulk_create(topic_links) + self.topic_stats = { + **{topic_id: dict(discussion=random.randint(0, 10), question=random.randint(0, 10)) + for topic_id in set(topic_ids)}, + topic_ids[0]: dict(discussion=0, question=0), + } + patcher = mock.patch( + 'lms.djangoapps.discussion.rest_api.api.get_course_commentable_counts', + mock.Mock(return_value=self.topic_stats), + ) + patcher.start() + self.addCleanup(patcher.stop) + self.url = reverse("course_topics_v3", kwargs={"course_id": str(self.course.id)}) + + def test_basic(self): + response = self.client.get(self.url) + data = json.loads(response.content.decode()) + expected_non_courseware_keys = [ + 'id', 'usage_key', 'name', 'thread_counts', 'enabled_in_context', + 'courseware' + ] + expected_courseware_keys = [ + 'id', 'block_id', 'lms_web_url', 'legacy_web_url', 'student_view_url', + 'type', 'display_name', 'children', 'courseware' + ] + assert response.status_code == 200 + assert len(data) == 2 + non_courseware_topic_keys = list(data[0].keys()) + assert non_courseware_topic_keys == expected_non_courseware_keys + courseware_topic_keys = list(data[1].keys()) + assert courseware_topic_keys == expected_courseware_keys + expected_courseware_keys.remove('courseware') + sequential_keys = list(data[1]['children'][0].keys()) + assert sequential_keys == expected_courseware_keys + expected_non_courseware_keys.remove('courseware') + vertical_keys = list(data[1]['children'][0]['children'][0].keys()) + assert vertical_keys == expected_non_courseware_keys + + @ddt.ddt @httpretty.activate @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) diff --git a/lms/djangoapps/discussion/rest_api/urls.py b/lms/djangoapps/discussion/rest_api/urls.py index be956edab8..b861bd7848 100644 --- a/lms/djangoapps/discussion/rest_api/urls.py +++ b/lms/djangoapps/discussion/rest_api/urls.py @@ -14,6 +14,7 @@ from lms.djangoapps.discussion.rest_api.views import ( CourseDiscussionSettingsAPIView, CourseTopicsView, CourseTopicsViewV2, + CourseTopicsViewV3, CourseView, LearnerThreadView, ReplaceUsernamesView, @@ -75,5 +76,10 @@ urlpatterns = [ CourseTopicsViewV2.as_view(), name="course_topics_v2" ), + re_path( + fr"^v3/course_topics/{settings.COURSE_ID_PATTERN}", + CourseTopicsViewV3.as_view(), + name="course_topics_v3" + ), path('v1/', include(ROUTER.urls)), ] diff --git a/lms/djangoapps/discussion/rest_api/utils.py b/lms/djangoapps/discussion/rest_api/utils.py index 96bca970c0..905f5fbcb1 100644 --- a/lms/djangoapps/discussion/rest_api/utils.py +++ b/lms/djangoapps/discussion/rest_api/utils.py @@ -159,3 +159,87 @@ def get_moderator_users_list(course_id): for user in role.users.all() ] return moderator_user_ids + + +def filter_topic_from_discussion_id(discussion_id, topics_list): + """ + Returns topic based on discussion id + """ + for topic in topics_list: + if topic.get("id") == discussion_id: + return topic + return {} + + +def create_discussion_children_from_ids(children_ids, blocks, topics): + """ + Takes ids of discussion and return discussion dictionary + """ + discussions = [] + for child_id in children_ids: + topic = blocks.get(child_id) + if topic.get('type') == 'vertical': + discussions_id = topic.get('discussions_id') + topic = filter_topic_from_discussion_id(discussions_id, topics) + if topic: + discussions.append(topic) + return discussions + + +def create_blocks_params(course_usage_key, user): + """ + Returns param dict that is needed to get blocks + """ + return { + 'usage_key': course_usage_key, + 'user': user, + 'depth': None, + 'nav_depth': None, + 'requested_fields': { + 'display_name', + 'student_view_data', + 'children', + 'discussions_id', + 'type', + 'block_types_filter' + }, + 'block_counts': set(), + 'student_view_data': {'discussion'}, + 'return_type': 'dict', + 'block_types_filter': { + 'discussion', + 'chapter', + 'vertical', + 'sequential', + 'course' + } + } + + +def create_topics_v3_structure(blocks, topics): + """ + Create V3 topics structure from blocks and v2 topics + """ + non_courseware_topics = [ + dict({**topic, 'courseware': False}) + for topic in topics + if topic.get('usage_key', '') is None + ] + courseware_topics = [] + for key, value in blocks.items(): + if value.get("type") == "chapter": + value['courseware'] = True + courseware_topics.append(value) + value['children'] = create_discussion_children_from_ids( + value['children'], + blocks, + topics, + ) + subsections = value.get('children') + for subsection in subsections: + subsection['children'] = create_discussion_children_from_ids( + subsection['children'], + blocks, + topics, + ) + return non_courseware_topics + courseware_topics diff --git a/lms/djangoapps/discussion/rest_api/views.py b/lms/djangoapps/discussion/rest_api/views.py index daed977e26..6bf8a8dca9 100644 --- a/lms/djangoapps/discussion/rest_api/views.py +++ b/lms/djangoapps/discussion/rest_api/views.py @@ -23,6 +23,7 @@ from rest_framework.viewsets import ViewSet from xmodule.modulestore.django import modulestore from common.djangoapps.util.file import store_uploaded_file +from lms.djangoapps.course_api.blocks.api import get_blocks from lms.djangoapps.course_goals.models import UserActivity from lms.djangoapps.discussion.django_comment_client import settings as cc_settings from lms.djangoapps.discussion.django_comment_client.utils import get_group_id_for_comments_service @@ -73,6 +74,11 @@ from ..rest_api.serializers import ( DiscussionTopicSerializerV2, TopicOrdering, ) +from .utils import ( + create_blocks_params, + create_topics_v3_structure, +) + log = logging.getLogger(__name__) @@ -292,6 +298,85 @@ class CourseTopicsViewV2(DeveloperErrorViewMixin, APIView): return Response(response) +@view_auth_classes() +class CourseTopicsViewV3(DeveloperErrorViewMixin, APIView): + """ + View for listing course topics v3. + + ** Response Example **: + [ + { + "id": "non-courseware-discussion-id", + "usage_key": None, + "name": "Non Courseware Topic", + "thread_counts": {"discussion": 0, "question": 0}, + "enabled_in_context": true, + "courseware": false + }, + { + "id": "id", + "block_id": "block_id", + "lms_web_url": "", + "legacy_web_url": "", + "student_view_url": "", + "type": "chapter", + "display_name": "First section", + "children": [ + "id": "id", + "block_id": "block_id", + "lms_web_url": "", + "legacy_web_url": "", + "student_view_url": "", + "type": "sequential", + "display_name": "First Sub-Section", + "children": [ + "id": "id", + "usage_key": "", + "name": "First Unit?", + "thread_counts": { "discussion": 0, "question": 0 }, + "enabled_in_context": true + ] + ], + "courseware": true, + } + ] + """ + + def get(self, request, course_id): + """ + **Use Cases** + + Retrieve the topic listing for a course. + + **Example Requests**: + + GET /api/discussion/v3/course_topics/course-v1:ExampleX+Subject101+2015 + """ + course_key = CourseKey.from_string(course_id) + topics = get_course_topics_v2( + course_key, + request.user, + ) + course_usage_key = modulestore().make_course_usage_key(course_key) + blocks_params = create_blocks_params(course_usage_key, request.user) + blocks = get_blocks( + request, + blocks_params['usage_key'], + blocks_params['user'], + blocks_params['depth'], + blocks_params['nav_depth'], + blocks_params['requested_fields'], + blocks_params['block_counts'], + blocks_params['student_view_data'], + blocks_params['return_type'], + blocks_params['block_types_filter'], + hide_access_denials=False, + )['blocks'] + + topics = create_topics_v3_structure(blocks, topics) + return Response(topics) + + @view_auth_classes() class ThreadViewSet(DeveloperErrorViewMixin, ViewSet): """