diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 4a01922edd49..e2128c4c389d 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,18 +1,3 @@ - - ## Description Describe what this pull request changes, and why. Include implications for people using this change. diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 548ea72b3e17..c0db64e5e188 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -3,7 +3,7 @@ version: 2 build: os: "ubuntu-22.04" tools: - python: "3.12" + python: "3.11" sphinx: configuration: docs/conf.py diff --git a/cms/djangoapps/contentstore/courseware_index.py b/cms/djangoapps/contentstore/courseware_index.py index 48647bf47bc6..d3b6f811d5f6 100644 --- a/cms/djangoapps/contentstore/courseware_index.py +++ b/cms/djangoapps/contentstore/courseware_index.py @@ -256,7 +256,8 @@ def prepare_item_index(item, skip_index=False, groups_usage_info=None): # Now index the content for item in structure.get_children(): prepare_item_index(item, groups_usage_info=groups_usage_info) - searcher.index(items_index, request_timeout=timeout) + if items_index: + searcher.index(items_index, request_timeout=timeout) cls.remove_deleted_items(searcher, structure_key, indexed_items) except Exception as err: # pylint: disable=broad-except # broad exception so that index operation does not prevent the rest of the application from working diff --git a/cms/djangoapps/contentstore/helpers.py b/cms/djangoapps/contentstore/helpers.py index e67e337e55fa..8ff1f1aa39cc 100644 --- a/cms/djangoapps/contentstore/helpers.py +++ b/cms/djangoapps/contentstore/helpers.py @@ -24,7 +24,7 @@ from xmodule.xml_block import XmlMixin from cms.djangoapps.models.settings.course_grading import CourseGradingModel -from cms.lib.xblock.upstream_sync import UpstreamLink, BadUpstream, BadDownstream, fetch_customizable_fields +from cms.lib.xblock.upstream_sync import UpstreamLink, UpstreamLinkException, fetch_customizable_fields from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers import openedx.core.djangoapps.content_staging.api as content_staging_api import openedx.core.djangoapps.content_tagging.api as content_tagging_api @@ -323,6 +323,56 @@ def import_staged_content_from_user_clipboard(parent_key: UsageKey, request) -> return new_xblock, notices +def _fetch_and_set_upstream_link( + copied_from_block: str, + copied_from_version_num: int, + temp_xblock: XBlock, + user: User +): + """ + Fetch and set upstream link for the given xblock. This function handles following cases: + * the xblock is copied from a v2 library; the library block is set as upstream. + * the xblock is copied from a course; no upstream is set, only copied_from_block is set. + * the xblock is copied from a course where the source block was imported from a library; the original libary block + is set as upstream. + """ + # Try to link the pasted block (downstream) to the copied block (upstream). + temp_xblock.upstream = copied_from_block + try: + UpstreamLink.get_for_block(temp_xblock) + except UpstreamLinkException: + # Usually this will fail. For example, if the copied block is a modulestore course block, it can't be an + # upstream. That's fine! Instead, we store a reference to where this block was copied from, in the + # 'copied_from_block' field (from AuthoringMixin). + + # In case if the source block was imported from a library, we need to check its upstream + # and set the same upstream link for the new block. + source_descriptor = modulestore().get_item(UsageKey.from_string(copied_from_block)) + if source_descriptor.upstream: + _fetch_and_set_upstream_link( + source_descriptor.upstream, + source_descriptor.upstream_version, + temp_xblock, + user, + ) + else: + # else we store a reference to where this block was copied from, in the 'copied_from_block' + # field (from AuthoringMixin). + temp_xblock.upstream = None + temp_xblock.copied_from_block = copied_from_block + else: + # But if it doesn't fail, then populate the `upstream_version` field based on what was copied. Note that + # this could be the latest published version, or it could be an an even newer draft version. + temp_xblock.upstream_version = copied_from_version_num + # Also, fetch upstream values (`upstream_display_name`, etc.). + # Recall that the copied block could be a draft. So, rather than fetching from the published upstream (which + # could be older), fetch from the copied block itself. That way, if an author customizes a field, but then + # later wants to restore it, it will restore to the value that the field had when the block was pasted. Of + # course, if the author later syncs updates from a *future* published upstream version, then that will fetch + # new values from the published upstream content. + fetch_customizable_fields(upstream=temp_xblock, downstream=temp_xblock, user=user) + + def _import_xml_node_to_parent( node, parent_xblock: XBlock, @@ -404,28 +454,7 @@ def _import_xml_node_to_parent( raise NotImplementedError("We don't yet support pasting XBlocks with children") temp_xblock.parent = parent_key if copied_from_block: - # Try to link the pasted block (downstream) to the copied block (upstream). - temp_xblock.upstream = copied_from_block - try: - UpstreamLink.get_for_block(temp_xblock) - except (BadDownstream, BadUpstream): - # Usually this will fail. For example, if the copied block is a modulestore course block, it can't be an - # upstream. That's fine! Instead, we store a reference to where this block was copied from, in the - # 'copied_from_block' field (from AuthoringMixin). - temp_xblock.upstream = None - temp_xblock.copied_from_block = copied_from_block - else: - # But if it doesn't fail, then populate the `upstream_version` field based on what was copied. Note that - # this could be the latest published version, or it could be an an even newer draft version. - temp_xblock.upstream_version = copied_from_version_num - # Also, fetch upstream values (`upstream_display_name`, etc.). - # Recall that the copied block could be a draft. So, rather than fetching from the published upstream (which - # could be older), fetch from the copied block itself. That way, if an author customizes a field, but then - # later wants to restore it, it will restore to the value that the field had when the block was pasted. Of - # course, if the author later syncs updates from a *future* published upstream version, then that will fetch - # new values from the published upstream content. - fetch_customizable_fields(upstream=temp_xblock, downstream=temp_xblock, user=user) - + _fetch_and_set_upstream_link(copied_from_block, copied_from_version_num, temp_xblock, user) # Save the XBlock into modulestore. We need to save the block and its parent for this to work: new_xblock = store.update_item(temp_xblock, user.id, allow_not_found=True) parent_xblock.children.append(new_xblock.location) @@ -436,7 +465,7 @@ def _import_xml_node_to_parent( # Allow an XBlock to do anything fancy it may need to when pasted from the clipboard. # These blocks may handle their own children or parenting if needed. Let them return booleans to # let us know if we need to handle these or not. - children_handed = new_xblock.studio_post_paste(store, node) + children_handled = new_xblock.studio_post_paste(store, node) if not children_handled: for child_node in child_nodes: diff --git a/cms/djangoapps/contentstore/management/commands/reindex_course.py b/cms/djangoapps/contentstore/management/commands/reindex_course.py index accbc077c4fc..8535e925bf3b 100644 --- a/cms/djangoapps/contentstore/management/commands/reindex_course.py +++ b/cms/djangoapps/contentstore/management/commands/reindex_course.py @@ -97,14 +97,16 @@ def handle(self, *args, **options): # pylint: disable=too-many-statements logging.exception('Search Engine error - %s', exc) return - index_exists = searcher._es.indices.exists(index=index_name) # pylint: disable=protected-access + # Legacy Elasticsearch engine + if hasattr(searcher, '_es'): # pylint: disable=protected-access + index_exists = searcher._es.indices.exists(index=index_name) # pylint: disable=protected-access - index_mapping = searcher._es.indices.get_mapping( # pylint: disable=protected-access - index=index_name, - ) if index_exists else {} + index_mapping = searcher._es.indices.get_mapping( # pylint: disable=protected-access + index=index_name, + ) if index_exists else {} - if index_exists and index_mapping: - return + if index_exists and index_mapping: + return # if reindexing is done during devstack setup step, don't prompt the user if setup_option or query_yes_no(self.CONFIRMATION_PROMPT, default="no"): diff --git a/cms/djangoapps/contentstore/rest_api/v1/serializers/home.py b/cms/djangoapps/contentstore/rest_api/v1/serializers/home.py index a81d391b3f69..fa2a651f8a28 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/serializers/home.py +++ b/cms/djangoapps/contentstore/rest_api/v1/serializers/home.py @@ -66,6 +66,7 @@ class StudioHomeSerializer(serializers.Serializer): request_course_creator_url = serializers.CharField() rerun_creator_status = serializers.BooleanField() show_new_library_button = serializers.BooleanField() + show_new_library_v2_button = serializers.BooleanField() split_studio_home = serializers.BooleanField() studio_name = serializers.CharField() studio_short_name = serializers.CharField() diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/home.py b/cms/djangoapps/contentstore/rest_api/v1/views/home.py index 06433d9f42d5..3de536d78092 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/home.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/home.py @@ -66,6 +66,7 @@ def get(self, request: Request): "request_course_creator_url": "/request_course_creator", "rerun_creator_status": true, "show_new_library_button": true, + "show_new_library_v2_button": true, "split_studio_home": false, "studio_name": "Studio", "studio_short_name": "Studio", diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_home.py b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_home.py index 69eee524373c..9a3d2c24d8f5 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_home.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_home.py @@ -2,7 +2,9 @@ Unit tests for home page view. """ import ddt +import pytz from collections import OrderedDict +from datetime import datetime, timedelta from django.conf import settings from django.test import override_settings from django.urls import reverse @@ -54,6 +56,7 @@ def setUp(self): "request_course_creator_url": "/request_course_creator", "rerun_creator_status": True, "show_new_library_button": True, + "show_new_library_v2_button": True, "split_studio_home": False, "studio_name": settings.STUDIO_NAME, "studio_short_name": settings.STUDIO_SHORT_NAME, @@ -100,12 +103,13 @@ class HomePageCoursesViewTest(CourseTestCase): def setUp(self): super().setUp() self.url = reverse("cms.djangoapps.contentstore:v1:courses") - CourseOverviewFactory.create( + self.course_overview = CourseOverviewFactory.create( id=self.course.id, org=self.course.org, display_name=self.course.display_name, display_number_with_default=self.course.number, ) + self.non_staff_client, _ = self.create_non_staff_authed_user_client() def test_home_page_response(self): """Check successful response content""" @@ -131,6 +135,7 @@ def test_home_page_response(self): print(response.data) self.assertDictEqual(expected_response, response.data) + @override_settings(FEATURES=FEATURES_WITH_HOME_PAGE_COURSE_V2_API) def test_home_page_response_with_api_v2(self): """Check successful response content with api v2 modifications. @@ -155,12 +160,88 @@ def test_home_page_response_with_api_v2(self): "in_process_course_actions": [], } - with override_settings(FEATURES=FEATURES_WITH_HOME_PAGE_COURSE_V2_API): - response = self.client.get(self.url) + response = self.client.get(self.url) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertDictEqual(expected_response, response.data) + @override_settings(FEATURES=FEATURES_WITH_HOME_PAGE_COURSE_V2_API) + @ddt.data( + ("active_only", "true", 2, 0), + ("archived_only", "true", 0, 1), + ("search", "sample", 1, 0), + ("search", "demo", 0, 1), + ("order", "org", 2, 1), + ("order", "display_name", 2, 1), + ("order", "number", 2, 1), + ("order", "run", 2, 1) + ) + @ddt.unpack + def test_filter_and_ordering_courses( + self, + filter_key, + filter_value, + expected_active_length, + expected_archived_length + ): + """Test home page with org filter and ordering for a staff user. + + The test creates an active/archived course, and then filters/orders them using the query parameters. + """ + archived_course_key = self.store.make_course_key("demo-org", "demo-number", "demo-run") + CourseOverviewFactory.create( + display_name="Course (Demo)", + id=archived_course_key, + org=archived_course_key.org, + end=(datetime.now() - timedelta(days=365)).replace(tzinfo=pytz.UTC), + ) + active_course_key = self.store.make_course_key("sample-org", "sample-number", "sample-run") + CourseOverviewFactory.create( + display_name="Course (Sample)", + id=active_course_key, + org=active_course_key.org, + ) + + response = self.client.get(self.url, {filter_key: filter_value}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data["archived_courses"]), expected_archived_length) + self.assertEqual(len(response.data["courses"]), expected_active_length) + + @override_settings(FEATURES=FEATURES_WITH_HOME_PAGE_COURSE_V2_API) + @ddt.data( + ("active_only", "true"), + ("archived_only", "true"), + ("search", "sample"), + ("order", "org"), + ) + @ddt.unpack + def test_filter_and_ordering_no_courses_staff(self, filter_key, filter_value): + """Test home page with org filter and ordering when there are no courses for a staff user.""" + self.course_overview.delete() + + response = self.client.get(self.url, {filter_key: filter_value}) + + self.assertEqual(len(response.data["courses"]), 0) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + @override_settings(FEATURES=FEATURES_WITH_HOME_PAGE_COURSE_V2_API) + @ddt.data( + ("active_only", "true"), + ("archived_only", "true"), + ("search", "sample"), + ("order", "org"), + ) + @ddt.unpack + def test_home_page_response_no_courses_non_staff(self, filter_key, filter_value): + """Test home page with org filter and ordering when there are no courses for a non-staff user.""" + self.course_overview.delete() + + response = self.non_staff_client.get(self.url) + + self.assertEqual(len(response.data["courses"]), 0) + self.assertEqual(response.status_code, status.HTTP_200_OK) + @override_waffle_switch(ENABLE_GLOBAL_STAFF_OPTIMIZATION, True) def test_org_query_if_passed(self): """Test home page when org filter passed as a query param""" diff --git a/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_home.py b/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_home.py index c0ffa50903cd..d2422267cc4d 100644 --- a/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_home.py +++ b/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_home.py @@ -45,6 +45,7 @@ def setUp(self): org=archived_course_key.org, end=(datetime.now() - timedelta(days=365)).replace(tzinfo=pytz.UTC), ) + self.non_staff_client, _ = self.create_non_staff_authed_user_client() def test_home_page_response(self): """Get list of courses available to the logged in user. @@ -239,3 +240,100 @@ def test_api_v2_is_disabled(self, mock_modulestore, mock_course_overview): self.assertEqual(response.status_code, status.HTTP_200_OK) mock_modulestore().get_course_summaries.assert_called_once() mock_course_overview.get_all_courses.assert_not_called() + + @ddt.data( + ("active_only", "true"), + ("archived_only", "true"), + ("search", "sample"), + ("order", "org"), + ("page", 1), + ) + @ddt.unpack + def test_if_empty_list_of_courses(self, query_param, value): + """Get list of courses when no courses are available. + + Expected result: + - An empty list of courses available to the logged in user. + """ + self.active_course.delete() + self.archived_course.delete() + + response = self.client.get(self.api_v2_url, {query_param: value}) + + self.assertEqual(len(response.data['results']['courses']), 0) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + @override_settings(FEATURES=FEATURES_WITH_HOME_PAGE_COURSE_V2_API) + @ddt.data( + ("active_only", "true", 2, 0), + ("archived_only", "true", 0, 1), + ("search", "foo", 1, 0), + ("search", "demo", 0, 1), + ("order", "org", 2, 1), + ("order", "display_name", 2, 1), + ("order", "number", 2, 1), + ("order", "run", 2, 1) + ) + @ddt.unpack + def test_filter_and_ordering_courses( + self, + filter_key, + filter_value, + expected_active_length, + expected_archived_length + ): + """Get list of courses when filter and ordering are applied. + + This test creates two courses besides the default courses created in the setUp method. + Then filters and orders them based on the filter_key and filter_value passed as query parameters. + + Expected result: + - A list of courses available to the logged in user for the specified filter and order. + """ + archived_course_key = self.store.make_course_key("demo-org", "demo-number", "demo-run") + CourseOverviewFactory.create( + display_name="Course (Demo)", + id=archived_course_key, + org=archived_course_key.org, + end=(datetime.now() - timedelta(days=365)).replace(tzinfo=pytz.UTC), + ) + active_course_key = self.store.make_course_key("foo-org", "foo-number", "foo-run") + CourseOverviewFactory.create( + display_name="Course (Foo)", + id=active_course_key, + org=active_course_key.org, + ) + + response = self.client.get(self.api_v2_url, {filter_key: filter_value}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + len([course for course in response.data["results"]["courses"] if course["is_active"]]), + expected_active_length + ) + self.assertEqual( + len([course for course in response.data["results"]["courses"] if not course["is_active"]]), + expected_archived_length + ) + + @ddt.data( + ("active_only", "true"), + ("archived_only", "true"), + ("search", "sample"), + ("order", "org"), + ("page", 1), + ) + @ddt.unpack + def test_if_empty_list_of_courses_non_staff(self, query_param, value): + """Get list of courses when no courses are available for non-staff users. + + Expected result: + - An empty list of courses available to the logged in user. + """ + self.active_course.delete() + self.archived_course.delete() + + response = self.non_staff_client.get(self.api_v2_url, {query_param: value}) + + self.assertEqual(len(response.data["results"]["courses"]), 0) + self.assertEqual(response.status_code, status.HTTP_200_OK) diff --git a/cms/djangoapps/contentstore/tests/test_courseware_index.py b/cms/djangoapps/contentstore/tests/test_courseware_index.py index 98a60dce901f..3ab3fa373f81 100644 --- a/cms/djangoapps/contentstore/tests/test_courseware_index.py +++ b/cms/djangoapps/contentstore/tests/test_courseware_index.py @@ -504,6 +504,11 @@ def test_delete_course_from_search_index_after_course_deletion(self): """ Test for removing course from CourseAboutSearchIndexer """ self._test_delete_course_from_search_index_after_course_deletion(self.store) + def test_empty_course(self): + empty_course = CourseFactory.create(modulestore=self.store, start=datetime(2015, 3, 1, tzinfo=UTC)) + added_to_index = CoursewareSearchIndexer.do_course_reindex(self.store, empty_course.id) + assert added_to_index == 0 + @patch('django.conf.settings.SEARCH_ENGINE', 'search.tests.utils.ForceRefreshElasticSearchEngine') @ddt.ddt diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py index 964f0c57e757..3f81a860dc59 100644 --- a/cms/djangoapps/contentstore/utils.py +++ b/cms/djangoapps/contentstore/utils.py @@ -1552,6 +1552,9 @@ def get_library_context(request, request_is_json=False): from cms.djangoapps.contentstore.views.library import ( user_can_view_create_library_button, ) + from openedx.core.djangoapps.content_libraries.api import ( + user_can_create_library, + ) libraries = _accessible_libraries_iter(request.user) if libraries_v1_enabled() else [] data = { @@ -1565,6 +1568,7 @@ def get_library_context(request, request_is_json=False): 'courses': [], 'libraries_enabled': libraries_v1_enabled(), 'show_new_library_button': user_can_view_create_library_button(request.user) and request.user.is_active, + 'show_new_library_v2_button': user_can_create_library(request.user), 'user': request.user, 'request_course_creator_url': reverse('request_course_creator'), 'course_creator_status': _get_course_creator_status(request.user), @@ -1686,6 +1690,9 @@ def get_home_context(request, no_course=False): from cms.djangoapps.contentstore.views.library import ( user_can_view_create_library_button, ) + from openedx.core.djangoapps.content_libraries.api import ( + user_can_create_library, + ) active_courses = [] archived_courses = [] @@ -1714,6 +1721,7 @@ def get_home_context(request, no_course=False): 'taxonomy_list_mfe_url': get_taxonomy_list_url(), 'libraries': libraries, 'show_new_library_button': user_can_view_create_library_button(user), + 'show_new_library_v2_button': user_can_create_library(user), 'user': user, 'request_course_creator_url': reverse('request_course_creator'), 'course_creator_status': _get_course_creator_status(user), diff --git a/cms/djangoapps/contentstore/views/component.py b/cms/djangoapps/contentstore/views/component.py index 46f2dd322efa..f4dcd66d9a7f 100644 --- a/cms/djangoapps/contentstore/views/component.py +++ b/cms/djangoapps/contentstore/views/component.py @@ -26,7 +26,12 @@ from common.djangoapps.xblock_django.api import authorable_xblocks, disabled_xblocks from common.djangoapps.xblock_django.models import XBlockStudioConfigurationFlag from cms.djangoapps.contentstore.helpers import is_unit -from cms.djangoapps.contentstore.toggles import libraries_v2_enabled, use_new_problem_editor, use_new_unit_page +from cms.djangoapps.contentstore.toggles import ( + libraries_v1_enabled, + libraries_v2_enabled, + use_new_problem_editor, + use_new_unit_page, +) from cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers import load_services_for_studio from openedx.core.lib.xblock_utils import get_aside_from_xblock, is_xblock_aside from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration @@ -274,7 +279,14 @@ def create_support_legend_dict(): component_types = COMPONENT_TYPES[:] # Libraries do not support discussions, drag-and-drop, and openassessment and other libraries - component_not_supported_by_library = ['discussion', 'library', 'openassessment', 'drag-and-drop-v2'] + component_not_supported_by_library = [ + 'discussion', + 'library', + 'openassessment', + 'drag-and-drop-v2', + 'library_v2', + 'itembank', + ] if library: component_types = [component for component in component_types if component not in set(component_not_supported_by_library)] @@ -488,6 +500,8 @@ def _filter_disabled_blocks(all_blocks): Filter out disabled xblocks from the provided list of xblock names. """ disabled_block_names = [block.name for block in disabled_xblocks()] + if not libraries_v1_enabled(): + disabled_block_names.append('library') if not libraries_v2_enabled(): disabled_block_names.append('library_v2') disabled_block_names.append('itembank') diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index 9f6cfb7c430e..244804c3062b 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -15,6 +15,7 @@ from django.contrib.auth import get_user_model from django.contrib.auth.decorators import login_required from django.core.exceptions import FieldError, PermissionDenied, ValidationError as DjangoValidationError +from django.db.models import QuerySet from django.http import Http404, HttpResponse, HttpResponseBadRequest, HttpResponseNotFound from django.shortcuts import redirect from django.urls import reverse @@ -575,6 +576,10 @@ def filter_ccx(course_access): if course_keys: courses_list = CourseOverview.get_all_courses(filter_={'id__in': course_keys}) + else: + # If no course keys are found for the current user, then return without filtering + # or ordering the courses list. + return courses_list, [] search_query, order, active_only, archived_only = get_query_params_if_present(request) courses_list = get_filtered_and_ordered_courses( @@ -588,7 +593,11 @@ def filter_ccx(course_access): return courses_list, [] -def get_courses_by_status(active_only, archived_only, course_overviews): +def get_courses_by_status( + active_only: bool, + archived_only: bool, + course_overviews: QuerySet[CourseOverview] +) -> QuerySet[CourseOverview]: """ Return course overviews based on a base queryset filtered by a status. @@ -602,7 +611,10 @@ def get_courses_by_status(active_only, archived_only, course_overviews): return CourseOverview.get_courses_by_status(active_only, archived_only, course_overviews) -def get_courses_by_search_query(search_query, course_overviews): +def get_courses_by_search_query( + search_query: str | None, + course_overviews: QuerySet[CourseOverview] +) -> QuerySet[CourseOverview]: """Return course overviews based on a base queryset filtered by a search query. Args: @@ -614,7 +626,10 @@ def get_courses_by_search_query(search_query, course_overviews): return CourseOverview.get_courses_matching_query(search_query, course_overviews=course_overviews) -def get_courses_order_by(order_query, course_overviews): +def get_courses_order_by( + order_query: str | None, + course_overviews: QuerySet[CourseOverview] +) -> QuerySet[CourseOverview]: """Return course overviews based on a base queryset ordered by a query. Args: diff --git a/cms/djangoapps/contentstore/views/tests/test_clipboard_paste.py b/cms/djangoapps/contentstore/views/tests/test_clipboard_paste.py index a818da81d10f..b9c9f0fdca41 100644 --- a/cms/djangoapps/contentstore/views/tests/test_clipboard_paste.py +++ b/cms/djangoapps/contentstore/views/tests/test_clipboard_paste.py @@ -456,26 +456,6 @@ def setUp(self): self.lib_block_tags = ['tag_1', 'tag_5'] tagging_api.tag_object(str(self.lib_block_key), taxonomy_all_org, self.lib_block_tags) - def test_paste_from_library_creates_link(self): - """ - When we copy a v2 lib block into a course, the dest block should be linked up to the lib block. - """ - copy_response = self.client.post(CLIPBOARD_ENDPOINT, {"usage_key": str(self.lib_block_key)}, format="json") - assert copy_response.status_code == 200 - - paste_response = self.client.post(XBLOCK_ENDPOINT, { - "parent_locator": str(self.course.usage_key), - "staged_content": "clipboard", - }, format="json") - assert paste_response.status_code == 200 - - new_block_key = UsageKey.from_string(paste_response.json()["locator"]) - new_block = modulestore().get_item(new_block_key) - assert new_block.upstream == str(self.lib_block_key) - assert new_block.upstream_version == 3 - assert new_block.upstream_display_name == "MCQ-draft" - assert new_block.upstream_max_attempts == 5 - def test_paste_from_library_read_only_tags(self): """ When we copy a v2 lib block into a course, the dest block should have read-only copied tags. @@ -556,6 +536,34 @@ def test_paste_from_library_copies_asset(self): assert image_asset.name == "1px.webp" assert image_asset.length == len(webp_raw_data) + def test_paste_from_course_block_imported_from_library_creates_link(self): + """ + When we copy a course xblock which was imported or copied from v2 lib block into a course, + the dest block should be linked up to the original lib block. + """ + def _copy_paste_and_assert_link(key_to_copy): + copy_response = self.client.post(CLIPBOARD_ENDPOINT, {"usage_key": str(key_to_copy)}, format="json") + assert copy_response.status_code == 200 + + paste_response = self.client.post(XBLOCK_ENDPOINT, { + "parent_locator": str(self.course.usage_key), + "staged_content": "clipboard", + }, format="json") + assert paste_response.status_code == 200 + + new_block_key = UsageKey.from_string(paste_response.json()["locator"]) + new_block = modulestore().get_item(new_block_key) + assert new_block.upstream == str(self.lib_block_key) + assert new_block.upstream_version == 3 + assert new_block.upstream_display_name == "MCQ-draft" + assert new_block.upstream_max_attempts == 5 + return new_block_key + + # first verify link for copied block from library + new_block_key = _copy_paste_and_assert_link(self.lib_block_key) + # next verify link for copied block from the pasted block + _copy_paste_and_assert_link(new_block_key) + class ClipboardPasteFromV1LibraryTestCase(ModuleStoreTestCase): """ diff --git a/cms/djangoapps/contentstore/views/tests/test_course_index.py b/cms/djangoapps/contentstore/views/tests/test_course_index.py index c3dcfe5305b7..a49273928062 100644 --- a/cms/djangoapps/contentstore/views/tests/test_course_index.py +++ b/cms/djangoapps/contentstore/views/tests/test_course_index.py @@ -433,13 +433,13 @@ def check_index_page(self, separate_archived_courses, org): @override_settings(FEATURES=FEATURES_WITHOUT_HOME_PAGE_COURSE_V2_API) @ddt.data( # Staff user has course staff access - (True, 'staff', None, 0, 21), - (False, 'staff', None, 0, 21), + (True, 'staff', None, 0, 23), + (False, 'staff', None, 0, 23), # Base user has global staff access - (True, 'user', ORG, 2, 21), - (False, 'user', ORG, 2, 21), - (True, 'user', None, 2, 21), - (False, 'user', None, 2, 21), + (True, 'user', ORG, 2, 23), + (False, 'user', ORG, 2, 23), + (True, 'user', None, 2, 23), + (False, 'user', None, 2, 23), ) @ddt.unpack def test_separate_archived_courses(self, separate_archived_courses, username, org, mongo_queries, sql_queries): @@ -464,13 +464,13 @@ def test_separate_archived_courses(self, separate_archived_courses, username, or @override_settings(FEATURES=FEATURES_WITH_HOME_PAGE_COURSE_V2_API) @ddt.data( # Staff user has course staff access - (True, 'staff', None, 0, 21), - (False, 'staff', None, 0, 21), + (True, 'staff', None, 0, 23), + (False, 'staff', None, 0, 23), # Base user has global staff access - (True, 'user', ORG, 0, 21), - (False, 'user', ORG, 0, 21), - (True, 'user', None, 0, 21), - (False, 'user', None, 0, 21), + (True, 'user', ORG, 0, 23), + (False, 'user', ORG, 0, 23), + (True, 'user', None, 0, 23), + (False, 'user', None, 0, 23), ) @ddt.unpack def test_separate_archived_courses_with_home_page_course_v2_api( diff --git a/cms/djangoapps/contentstore/views/tests/test_library.py b/cms/djangoapps/contentstore/views/tests/test_library.py index 8278cd0535bb..b6c5765c5108 100644 --- a/cms/djangoapps/contentstore/views/tests/test_library.py +++ b/cms/djangoapps/contentstore/views/tests/test_library.py @@ -403,6 +403,8 @@ def test_get_component_templates(self): self.assertNotIn('advanced', templates) self.assertNotIn('openassessment', templates) self.assertNotIn('library', templates) + self.assertNotIn('library_v2', templates) + self.assertNotIn('itembank', templates) def test_advanced_problem_types(self): """ diff --git a/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py b/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py index df8d8dc6251f..3b9ba5798a01 100644 --- a/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py +++ b/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py @@ -558,6 +558,7 @@ def _create_block(request): "locator": str(created_xblock.location), "courseKey": str(created_xblock.location.course_key), "static_file_notices": asdict(notices), + "upstreamRef": str(created_xblock.upstream), }) category = request.json["category"] diff --git a/cms/envs/common.py b/cms/envs/common.py index ea374bca8bb3..228bbc834d4c 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -2726,7 +2726,7 @@ PASSWORD_RESET_EMAIL_RATE = '2/h' ######################## Setting for content libraries ######################## -MAX_BLOCKS_PER_CONTENT_LIBRARY = 1000 +MAX_BLOCKS_PER_CONTENT_LIBRARY = 100_000 ################# Student Verification ################# VERIFY_STUDENT = { diff --git a/common/djangoapps/static_replace/__init__.py b/common/djangoapps/static_replace/__init__.py index 310ad8343242..cfab4dde8375 100644 --- a/common/djangoapps/static_replace/__init__.py +++ b/common/djangoapps/static_replace/__init__.py @@ -11,7 +11,7 @@ from xmodule.contentstore.content import StaticContent log = logging.getLogger(__name__) -XBLOCK_STATIC_RESOURCE_PREFIX = '/static/xblock' +XBLOCK_STATIC_RESOURCE_PREFIX = '/static/xblock/' def _url_replace_regex(prefix): diff --git a/common/djangoapps/static_replace/test/test_static_replace.py b/common/djangoapps/static_replace/test/test_static_replace.py index 1bdb770cad31..c71f8fa15c6a 100644 --- a/common/djangoapps/static_replace/test/test_static_replace.py +++ b/common/djangoapps/static_replace/test/test_static_replace.py @@ -86,6 +86,16 @@ def processor(__, prefix, quote, rest): # pylint: disable=redefined-outer-name assert process_static_urls(STATIC_SOURCE, processor) == '"test/static/file.png"' +def test_process_url_no_match_starts_with_xblock(): + def processor(original, prefix, quote, rest): # pylint: disable=unused-argument, redefined-outer-name + return quote + 'test' + prefix + rest + quote + assert process_static_urls( + '"/static/xblock-file.png"', + processor, + data_dir=DATA_DIRECTORY + ) == '"test/static/xblock-file.png"' + + @patch('django.http.HttpRequest', autospec=True) def test_static_urls(mock_request): mock_request.build_absolute_uri = lambda url: 'http://' + url diff --git a/docs/conf.py b/docs/conf.py index 7352195cbb11..b755f3986c93 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -55,7 +55,6 @@ # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ - 'sphinx.ext.autodoc', 'sphinx.ext.coverage', 'sphinx.ext.doctest', 'sphinx.ext.graphviz', @@ -68,8 +67,22 @@ 'sphinx_design', 'code_annotations.contrib.sphinx.extensions.featuretoggles', 'code_annotations.contrib.sphinx.extensions.settings', + # 'autoapi.extension', # Temporarily disabled ] +# Temporarily disabling autoapi_dirs and the AutoAPI extension due to performance issues. +# This will unblock ReadTheDocs builds and will be revisited for optimization. +# autoapi_type = 'python' +# autoapi_dirs = ['../lms/djangoapps', '../openedx/core/djangoapps', "../openedx/features"] +# +# autoapi_ignore = [ +# '*/migrations/*', +# '*/tests/*', +# '*.pyc', +# '__init__.py', +# '**/xblock_serializer/data.py', +# ] + # Rediraffe related settings. rediraffe_redirects = "redirects.txt" rediraffe_branch = 'origin/master' @@ -274,16 +287,9 @@ # Example configuration for intersphinx: refer to the Python standard library. intersphinx_mapping = { - 'django': ('https://docs.djangoproject.com/en/1.11/', 'https://docs.djangoproject.com/en/1.11/_objects/'), + 'django': ('https://docs.djangoproject.com/en/4.2/', 'https://docs.djangoproject.com/en/4.2/_objects/'), } -# Mock out these external modules during code import to avoid errors -autodoc_mock_imports = [ - 'MySQLdb', - 'django_mysql', - 'pymongo', -] - # Start building a map of the directories relative to the repository root to # run sphinx-apidoc against and the directories under "docs" in which to store # the generated *.rst files diff --git a/docs/docs_settings.py b/docs/docs_settings.py index f12848876e8a..422abaa73672 100644 --- a/docs/docs_settings.py +++ b/docs/docs_settings.py @@ -36,6 +36,7 @@ 'cms.djangoapps.course_creators', 'cms.djangoapps.xblock_config.apps.XBlockConfig', 'lms.djangoapps.lti_provider', + 'openedx.core.djangoapps.content.search', ]) diff --git a/docs/lms-openapi.yaml b/docs/lms-openapi.yaml index 5e9afcc6d370..a3410d2a634d 100644 --- a/docs/lms-openapi.yaml +++ b/docs/lms-openapi.yaml @@ -3975,6 +3975,7 @@ paths: "profile_name": "Jon Doe" "verification_attempt_id": (Optional) "proctored_exam_attempt_id": (Optional) + "platform_verification_attempt_id": (Optional) "status": (Optional) } parameters: @@ -4130,6 +4131,7 @@ paths: "profile_name": "Jon Doe" "verification_attempt_id": (Optional) "proctored_exam_attempt_id": (Optional) + "platform_verification_attempt_id": (Optional) "status": (Optional) } parameters: @@ -5809,7 +5811,7 @@ paths: operationId: grades_v1_submission_history_read description: |- Get submission history details. This submission history is related to only - ProblemBlock and it doesn't support LibraryContentBlock or ContentLibraries + ProblemBlock and it doesn't support LegacyLibraryContentBlock or ContentLibraries as of now. **Usecases**: @@ -6788,6 +6790,59 @@ paths: in: path required: true type: string + /mobile/{api_version}/notifications/create-token/: + post: + operationId: mobile_notifications_create-token_create + summary: |- + **Use Case** + This endpoint allows clients to register a device for push notifications. + description: |- + If the device is already registered, the existing registration will be updated. + If setting PUSH_NOTIFICATIONS_SETTINGS is not configured, the endpoint will return a 501 error. + + **Example Request** + POST /api/mobile/{version}/notifications/create-token/ + **POST Parameters** + The body of the POST request can include the following parameters. + * name (optional) - A name of the device. + * registration_id (required) - The device token of the device. + * device_id (optional) - ANDROID_ID / TelephonyManager.getDeviceId() (always as hex) + * active (optional) - Whether the device is active, default is True. + If False, the device will not receive notifications. + * cloud_message_type (required) - You should choose FCM or GCM. Currently, only FCM is supported. + * application_id (optional) - Opaque application identity, should be filled in for multiple + key/certificate access. Should be equal settings.FCM_APP_NAME. + **Example Response** + ```json + { + "id": 1, + "name": "My Device", + "registration_id": "fj3j4", + "device_id": 1234, + "active": true, + "date_created": "2024-04-18T07:39:37.132787Z", + "cloud_message_type": "FCM", + "application_id": "my_app_id" + } + ``` + parameters: + - name: data + in: body + required: true + schema: + $ref: '#/definitions/GCMDevice' + responses: + '201': + description: '' + schema: + $ref: '#/definitions/GCMDevice' + tags: + - mobile + parameters: + - name: api_version + in: path + required: true + type: string /mobile/{api_version}/users/{username}: get: operationId: mobile_users_read @@ -6956,6 +7011,25 @@ paths: in: path required: true type: string + /mobile/{api_version}/users/{username}/enrollments_status/: + get: + operationId: mobile_users_enrollments_status_list + description: Gets user's enrollments status. + parameters: [] + responses: + '200': + description: '' + tags: + - mobile + parameters: + - name: api_version + in: path + required: true + type: string + - name: username + in: path + required: true + type: string /notifications/: get: operationId: notifications_list @@ -8849,22 +8923,6 @@ paths: tags: - user parameters: [] - /user/v1/accounts/verifications/{attempt_id}/: - get: - operationId: user_v1_accounts_verifications_read - description: Get IDV attempt details by attempt_id. Only accessible by global - staff. - parameters: [] - responses: - '200': - description: '' - tags: - - user - parameters: - - name: attempt_id - in: path - required: true - type: string /user/v1/accounts/{username}: get: operationId: user_v1_accounts_read @@ -9773,7 +9831,7 @@ paths: required: true type: string pattern: ^[a-zA-Z0-9\-_]*$ - /xblock/v2/xblocks/{usage_key_str}/: + /xblock/v2/xblocks/{usage_key}/: get: operationId: xblock_v2_xblocks_read summary: Get metadata about the specified block. @@ -9789,11 +9847,11 @@ paths: tags: - xblock parameters: - - name: usage_key_str + - name: usage_key in: path required: true type: string - /xblock/v2/xblocks/{usage_key_str}/fields/: + /xblock/v2/xblocks/{usage_key}/fields/: get: operationId: xblock_v2_xblocks_fields_list description: retrieves the xblock, returning display_name, data, and metadata @@ -9814,11 +9872,11 @@ paths: tags: - xblock parameters: - - name: usage_key_str + - name: usage_key in: path required: true type: string - /xblock/v2/xblocks/{usage_key_str}/handler_url/{handler_name}/: + /xblock/v2/xblocks/{usage_key}/handler_url/{handler_name}/: get: operationId: xblock_v2_xblocks_handler_url_read summary: |- @@ -9833,7 +9891,7 @@ paths: tags: - xblock parameters: - - name: usage_key_str + - name: usage_key in: path required: true type: string @@ -9841,7 +9899,7 @@ paths: in: path required: true type: string - /xblock/v2/xblocks/{usage_key_str}/view/{view_name}/: + /xblock/v2/xblocks/{usage_key}/view/{view_name}/: get: operationId: xblock_v2_xblocks_view_read description: Get the HTML, JS, and CSS needed to render the given XBlock. @@ -9852,7 +9910,110 @@ paths: tags: - xblock parameters: - - name: usage_key_str + - name: usage_key + in: path + required: true + type: string + - name: view_name + in: path + required: true + type: string + /xblock/v2/xblocks/{usage_key}@{version}/: + get: + operationId: xblock_v2_xblocks_read + summary: Get metadata about the specified block. + description: |- + Accepts the following query parameters: + + * "include": a comma-separated list of keys to include. + Valid keys are "index_dictionary" and "student_view_data". + parameters: [] + responses: + '200': + description: '' + tags: + - xblock + parameters: + - name: usage_key + in: path + required: true + type: string + - name: version + in: path + required: true + type: string + /xblock/v2/xblocks/{usage_key}@{version}/fields/: + get: + operationId: xblock_v2_xblocks_fields_list + description: retrieves the xblock, returning display_name, data, and metadata + parameters: [] + responses: + '200': + description: '' + tags: + - xblock + post: + operationId: xblock_v2_xblocks_fields_create + description: edits the xblock, saving changes to data and metadata only (display_name + included in metadata) + parameters: [] + responses: + '201': + description: '' + tags: + - xblock + parameters: + - name: usage_key + in: path + required: true + type: string + - name: version + in: path + required: true + type: string + /xblock/v2/xblocks/{usage_key}@{version}/handler_url/{handler_name}/: + get: + operationId: xblock_v2_xblocks_handler_url_read + summary: |- + Get an absolute URL which can be used (without any authentication) to call + the given XBlock handler. + description: The URL will expire but is guaranteed to be valid for a minimum + of 2 days. + parameters: [] + responses: + '200': + description: '' + tags: + - xblock + parameters: + - name: usage_key + in: path + required: true + type: string + - name: version + in: path + required: true + type: string + - name: handler_name + in: path + required: true + type: string + /xblock/v2/xblocks/{usage_key}@{version}/view/{view_name}/: + get: + operationId: xblock_v2_xblocks_view_read + description: Get the HTML, JS, and CSS needed to render the given XBlock. + parameters: [] + responses: + '200': + description: '' + tags: + - xblock + parameters: + - name: usage_key + in: path + required: true + type: string + - name: version in: path required: true type: string @@ -10047,6 +10208,7 @@ definitions: required: - celebrations - course_access + - studio_access - course_id - is_enrolled - is_self_paced @@ -10084,6 +10246,9 @@ definitions: additionalProperties: type: string x-nullable: true + studio_access: + title: Studio access + type: boolean course_id: title: Course id type: string @@ -11237,10 +11402,24 @@ definitions: title: Verification attempt id type: integer x-nullable: true + verification_attempt_status: + title: Verification attempt status + type: string + minLength: 1 + x-nullable: true proctored_exam_attempt_id: title: Proctored exam attempt id type: integer x-nullable: true + platform_verification_attempt_id: + title: Platform verification attempt id + type: integer + x-nullable: true + platform_verification_attempt_status: + title: Platform verification attempt status + type: string + minLength: 1 + x-nullable: true status: title: Status type: string @@ -11277,10 +11456,24 @@ definitions: title: Verification attempt id type: integer x-nullable: true + verification_attempt_status: + title: Verification attempt status + type: string + minLength: 1 + x-nullable: true proctored_exam_attempt_id: title: Proctored exam attempt id type: integer x-nullable: true + platform_verification_attempt_id: + title: Platform verification attempt id + type: integer + x-nullable: true + platform_verification_attempt_status: + title: Platform verification attempt status + type: string + minLength: 1 + x-nullable: true status: title: Status type: string @@ -11710,6 +11903,52 @@ definitions: title: Enddatetime type: string format: date-time + GCMDevice: + required: + - registration_id + type: object + properties: + id: + title: ID + type: integer + name: + title: Name + type: string + maxLength: 255 + x-nullable: true + registration_id: + title: Registration ID + type: string + minLength: 1 + device_id: + title: Device id + description: 'ANDROID_ID / TelephonyManager.getDeviceId() (e.g: 0x01)' + type: integer + x-nullable: true + active: + title: Is active + description: Inactive devices will not be sent notifications + type: boolean + date_created: + title: Creation date + type: string + format: date-time + readOnly: true + x-nullable: true + cloud_message_type: + title: Cloud Message Type + description: You should choose FCM, GCM is deprecated + type: string + enum: + - FCM + - GCM + application_id: + title: Application ID + description: Opaque application identity, should be filled in for multiple + key/certificate access + type: string + maxLength: 64 + x-nullable: true mobile_api.User: required: - username diff --git a/lms/djangoapps/course_blocks/api.py b/lms/djangoapps/course_blocks/api.py index c978f54e02d5..de4e0443fb08 100644 --- a/lms/djangoapps/course_blocks/api.py +++ b/lms/djangoapps/course_blocks/api.py @@ -108,4 +108,5 @@ def get_course_blocks( transformers, starting_block_usage_key, collected_block_structure, + user, ) diff --git a/lms/djangoapps/course_blocks/tests/test_api.py b/lms/djangoapps/course_blocks/tests/test_api.py new file mode 100644 index 000000000000..52e9c86ec324 --- /dev/null +++ b/lms/djangoapps/course_blocks/tests/test_api.py @@ -0,0 +1,149 @@ +# pylint: disable=attribute-defined-outside-init +""" +Tests for course_blocks API +""" + +from unittest.mock import Mock, patch + +import ddt +from django.http.request import HttpRequest + +from common.djangoapps.student.tests.factories import UserFactory +from lms.djangoapps.course_blocks.api import get_course_blocks +from lms.djangoapps.course_blocks.transformers.tests.helpers import CourseStructureTestCase +from lms.djangoapps.course_blocks.transformers.tests.test_user_partitions import UserPartitionTestMixin +from lms.djangoapps.courseware.block_render import make_track_function, prepare_runtime_for_user +from openedx.core.djangoapps.content.block_structure.transformers import BlockStructureTransformers +from openedx.core.djangoapps.course_groups.cohorts import add_user_to_cohort +from xmodule.modulestore.django import modulestore + + +def get_block_side_effect(block_locator, user_known): + """ + Side effect for `CachingDescriptorSystem.get_block` + """ + store = modulestore() + course = store.get_course(block_locator.course_key) + block = store.get_item(block_locator) + runtime = block.runtime + user = UserFactory.create() + user.known = user_known + + prepare_runtime_for_user( + user=user, + student_data=Mock(), + runtime=runtime, + course_id=block_locator.course_key, + track_function=make_track_function(HttpRequest()), + request_token=Mock(), + course=course, + ) + return block.runtime.get_block_for_descriptor(block) + + +def get_block_side_effect_for_known_user(self, *args, **kwargs): + """ + Side effect for known user test. + """ + return get_block_side_effect(self, True) + + +def get_block_side_effect_for_unknown_user(self, *args, **kwargs): + """ + Side effect for unknown user test. + """ + return get_block_side_effect(self, False) + + +@ddt.ddt +class TestGetCourseBlocks(UserPartitionTestMixin, CourseStructureTestCase): + """ + Tests `get_course_blocks` API + """ + + def setup_partitions_and_course(self): + """ + Setup course structure. + """ + # Set up user partitions and groups. + self.setup_groups_partitions(active=True, num_groups=1) + self.user_partition = self.user_partitions[0] + + # Build course. + self.course_hierarchy = self.get_course_hierarchy() + self.blocks = self.build_course(self.course_hierarchy) + self.course = self.blocks['course'] + + # Set up cohorts. + self.setup_cohorts(self.course) + + def get_course_hierarchy(self): + """ + Returns a course hierarchy to test with. + """ + # course + # / \ + # / \ + # A[0] B + # | + # | + # O + + return [ + { + 'org': 'UserPartitionTransformer', + 'course': 'UP101F', + 'run': 'test_run', + 'user_partitions': [self.user_partition], + '#type': 'course', + '#ref': 'course', + '#children': [ + { + '#type': 'vertical', + '#ref': 'A', + 'metadata': {'group_access': {self.user_partition.id: [0]}}, + }, + {'#type': 'vertical', '#ref': 'B'}, + ], + }, + { + '#type': 'vertical', + '#ref': 'O', + '#parents': ['B'], + }, + ] + + @ddt.data( + (1, ('course', 'B', 'O'), True), + (1, ('course', 'A', 'B', 'O'), False), + (None, ('course', 'B', 'O'), True), + (None, ('course', 'A', 'B', 'O'), False), + ) + @ddt.unpack + def test_get_course_blocks(self, group_id, expected_blocks, user_known): + """ + Tests that `get_course_blocks` returns blocks without access checks for unknown users. + + Access checks are done through the transformers and through Runtime get_block_for_descriptor. Due + to the runtime limitations during the tests, the Runtime access checks are not performed as + get_block_for_descriptor is never called and Block is returned by CachingDescriptorSystem.get_block. + In this test, we mock the CachingDescriptorSystem.get_block and check block access for known and unknown users. + For known users, it performs the Runtime access checks through get_block_for_descriptor. For unknown, it + skips the access checks. + """ + self.setup_partitions_and_course() + if group_id: + cohort = self.partition_cohorts[self.user_partition.id - 1][group_id - 1] + add_user_to_cohort(cohort, self.user.username) + + side_effect = get_block_side_effect_for_known_user if user_known else get_block_side_effect_for_unknown_user + with patch('xmodule.modulestore.split_mongo.split.CachingDescriptorSystem.get_block', side_effect=side_effect): + block_structure = get_course_blocks( + self.user, + self.course.location, + BlockStructureTransformers([]), + ) + self.assertSetEqual( + set(block_structure.get_block_keys()), + self.get_block_key_set(self.blocks, *expected_blocks) + ) diff --git a/lms/djangoapps/discussion/django_comment_client/base/tests.py b/lms/djangoapps/discussion/django_comment_client/base/tests.py index 62af24f0ee37..df087fdc533e 100644 --- a/lms/djangoapps/discussion/django_comment_client/base/tests.py +++ b/lms/djangoapps/discussion/django_comment_client/base/tests.py @@ -82,6 +82,7 @@ def _set_mock_request_data(self, mock_request, data): @patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) +@patch('openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', autospec=True) class CreateThreadGroupIdTestCase( MockRequestSetupMixin, CohortedTestCase, @@ -90,7 +91,21 @@ class CreateThreadGroupIdTestCase( ): cs_endpoint = "/threads" - def call_view(self, mock_request, commentable_id, user, group_id, pass_group_id=True): + def setUp(self): + super().setUp() + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment" + ) + self.mock_get_course_id_by_comment = patcher.start() + self.addCleanup(patcher.stop) + + def call_view(self, mock_is_forum_v2_enabled, mock_request, commentable_id, user, group_id, pass_group_id=True): + mock_is_forum_v2_enabled.return_value = False self._set_mock_request_data(mock_request, {}) request_data = {"body": "body", "title": "title", "thread_type": "discussion"} if pass_group_id: @@ -105,8 +120,9 @@ def call_view(self, mock_request, commentable_id, user, group_id, pass_group_id= commentable_id=commentable_id ) - def test_group_info_in_response(self, mock_request): + def test_group_info_in_response(self, mock_is_forum_v2_enabled, mock_request): response = self.call_view( + mock_is_forum_v2_enabled, mock_request, "cohorted_topic", self.student, @@ -116,6 +132,7 @@ def test_group_info_in_response(self, mock_request): @patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) +@patch('openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', autospec=True) @disable_signal(views, 'thread_edited') @disable_signal(views, 'thread_voted') @disable_signal(views, 'thread_deleted') @@ -127,11 +144,18 @@ class ThreadActionGroupIdTestCase( def call_view( self, view_name, + mock_is_forum_v2_enabled, mock_request, user=None, post_params=None, view_args=None ): + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) + mock_is_forum_v2_enabled.return_value = False self._set_mock_request_data( mock_request, { @@ -154,53 +178,58 @@ def call_view( **(view_args or {}) ) - def test_update(self, mock_request): + def test_update(self, mock_is_forum_v2_enabled, mock_request): response = self.call_view( "update_thread", + mock_is_forum_v2_enabled, mock_request, post_params={"body": "body", "title": "title"} ) self._assert_json_response_contains_group_info(response) - def test_delete(self, mock_request): - response = self.call_view("delete_thread", mock_request) + def test_delete(self, mock_is_forum_v2_enabled, mock_request): + response = self.call_view("delete_thread", mock_is_forum_v2_enabled, mock_request) self._assert_json_response_contains_group_info(response) - def test_vote(self, mock_request): + def test_vote(self, mock_is_forum_v2_enabled, mock_request): response = self.call_view( "vote_for_thread", + mock_is_forum_v2_enabled, mock_request, view_args={"value": "up"} ) self._assert_json_response_contains_group_info(response) - response = self.call_view("undo_vote_for_thread", mock_request) + response = self.call_view("undo_vote_for_thread", mock_is_forum_v2_enabled, mock_request) self._assert_json_response_contains_group_info(response) - def test_flag(self, mock_request): + def test_flag(self, mock_is_forum_v2_enabled, mock_request): with mock.patch('openedx.core.djangoapps.django_comment_common.signals.thread_flagged.send') as signal_mock: - response = self.call_view("flag_abuse_for_thread", mock_request) + response = self.call_view("flag_abuse_for_thread", mock_is_forum_v2_enabled, mock_request) self._assert_json_response_contains_group_info(response) self.assertEqual(signal_mock.call_count, 1) - response = self.call_view("un_flag_abuse_for_thread", mock_request) + response = self.call_view("un_flag_abuse_for_thread", mock_is_forum_v2_enabled, mock_request) self._assert_json_response_contains_group_info(response) - def test_pin(self, mock_request): + def test_pin(self, mock_is_forum_v2_enabled, mock_request): response = self.call_view( "pin_thread", + mock_is_forum_v2_enabled, mock_request, user=self.moderator ) self._assert_json_response_contains_group_info(response) response = self.call_view( "un_pin_thread", + mock_is_forum_v2_enabled, mock_request, user=self.moderator ) self._assert_json_response_contains_group_info(response) - def test_openclose(self, mock_request): + def test_openclose(self, mock_is_forum_v2_enabled, mock_request): response = self.call_view( "openclose_thread", + mock_is_forum_v2_enabled, mock_request, user=self.moderator ) @@ -280,10 +309,11 @@ def _setup_mock_request(self, mock_request, include_depth=False): data["depth"] = 0 self._set_mock_request_data(mock_request, data) - def create_thread_helper(self, mock_request, extra_request_data=None, extra_response_data=None): + def create_thread_helper(self, mock_is_forum_v2_enabled, mock_request, extra_request_data=None, extra_response_data=None): """ Issues a request to create a thread and verifies the result. """ + mock_is_forum_v2_enabled.return_value = False self._set_mock_request_data(mock_request, { "thread_type": "discussion", "title": "Hello", @@ -350,10 +380,11 @@ def create_thread_helper(self, mock_request, extra_request_data=None, extra_resp ) assert response.status_code == 200 - def update_thread_helper(self, mock_request): + def update_thread_helper(self, mock_is_forum_v2_enabled, mock_request): """ Issues a request to update a thread and verifies the result. """ + mock_is_forum_v2_enabled.return_value = False self._setup_mock_request(mock_request) # Mock out saving in order to test that content is correctly # updated. Otherwise, the call to thread.save() receives the @@ -376,6 +407,7 @@ def update_thread_helper(self, mock_request): @ddt.ddt @patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) +@patch('openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', autospec=True) @disable_signal(views, 'thread_created') @disable_signal(views, 'thread_edited') class ViewsQueryCountTestCase( @@ -393,6 +425,11 @@ class ViewsQueryCountTestCase( @patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) def setUp(self): super().setUp() + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) def count_queries(func): # pylint: disable=no-self-argument """ @@ -414,22 +451,23 @@ def inner(self, default_store, block_count, mongo_calls, sql_queries, *args, **k ) @ddt.unpack @count_queries - def test_create_thread(self, mock_request): - self.create_thread_helper(mock_request) + def test_create_thread(self, mock_is_forum_v2_enabled, mock_request): + self.create_thread_helper(mock_is_forum_v2_enabled, mock_request) @ddt.data( (ModuleStoreEnum.Type.split, 3, 6, 41), ) @ddt.unpack @count_queries - def test_update_thread(self, mock_request): - self.update_thread_helper(mock_request) + def test_update_thread(self, mock_is_forum_v2_enabled, mock_request): + self.update_thread_helper(mock_is_forum_v2_enabled, mock_request) @ddt.ddt @disable_signal(views, 'comment_flagged') @disable_signal(views, 'thread_flagged') @patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) +@patch('openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', autospec=True) class ViewsTestCase( ForumsEnableMixin, UrlResetMixin, @@ -464,7 +502,16 @@ def setUp(self): # so we need to call super.setUp() which reloads urls.py (because # of the UrlResetMixin) super().setUp() - + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment" + ) + self.mock_get_course_id_by_comment = patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) # Patch the comment client user save method so it does not try # to create a new cc user when creating a django user with patch('common.djangoapps.student.models.user.cc.User.save'): @@ -497,11 +544,11 @@ def assert_discussion_signals(self, signal, user=None): with self.assert_signal_sent(views, signal, sender=None, user=user, exclude_args=('post',)): yield - def test_create_thread(self, mock_request): + def test_create_thread(self, mock_is_forum_v2_enabled, mock_request): with self.assert_discussion_signals('thread_created'): - self.create_thread_helper(mock_request) + self.create_thread_helper(mock_is_forum_v2_enabled, mock_request) - def test_create_thread_standalone(self, mock_request): + def test_create_thread_standalone(self, mock_is_forum_v2_enabled, mock_request): team = CourseTeamFactory.create( name="A Team", course_id=self.course_id, @@ -513,15 +560,15 @@ def test_create_thread_standalone(self, mock_request): team.add_user(self.student) # create_thread_helper verifies that extra data are passed through to the comments service - self.create_thread_helper(mock_request, extra_response_data={'context': ThreadContext.STANDALONE}) + self.create_thread_helper(mock_is_forum_v2_enabled, mock_request, extra_response_data={'context': ThreadContext.STANDALONE}) @ddt.data( ('follow_thread', 'thread_followed'), ('unfollow_thread', 'thread_unfollowed'), ) @ddt.unpack - def test_follow_unfollow_thread_signals(self, view_name, signal, mock_request): - self.create_thread_helper(mock_request) + def test_follow_unfollow_thread_signals(self, view_name, signal, mock_is_forum_v2_enabled, mock_request): + self.create_thread_helper(mock_is_forum_v2_enabled, mock_request) with self.assert_discussion_signals(signal): response = self.client.post( @@ -532,7 +579,8 @@ def test_follow_unfollow_thread_signals(self, view_name, signal, mock_request): ) assert response.status_code == 200 - def test_delete_thread(self, mock_request): + def test_delete_thread(self, mock_is_forum_v2_enabled, mock_request): + mock_is_forum_v2_enabled.return_value = False self._set_mock_request_data(mock_request, { "user_id": str(self.student.id), "closed": False, @@ -551,7 +599,8 @@ def test_delete_thread(self, mock_request): assert response.status_code == 200 assert mock_request.called - def test_delete_comment(self, mock_request): + def test_delete_comment(self, mock_is_forum_v2_enabled, mock_request): + mock_is_forum_v2_enabled.return_value = False self._set_mock_request_data(mock_request, { "user_id": str(self.student.id), "closed": False, @@ -573,12 +622,13 @@ def test_delete_comment(self, mock_request): assert args[0] == 'delete' assert args[1].endswith(f"/{test_comment_id}") - def _test_request_error(self, view_name, view_kwargs, data, mock_request): + def _test_request_error(self, view_name, view_kwargs, data, mock_is_forum_v2_enabled, mock_request): """ Submit a request against the given view with the given data and ensure that the result is a 400 error and that no data was posted using mock_request """ + mock_is_forum_v2_enabled.return_value = False self._setup_mock_request(mock_request, include_depth=(view_name == "create_sub_comment")) response = self.client.post(reverse(view_name, kwargs=view_kwargs), data=data) @@ -586,87 +636,97 @@ def _test_request_error(self, view_name, view_kwargs, data, mock_request): for call in mock_request.call_args_list: assert call[0][0].lower() == 'get' - def test_create_thread_no_title(self, mock_request): + def test_create_thread_no_title(self, mock_is_forum_v2_enabled, mock_request): self._test_request_error( "create_thread", {"commentable_id": "dummy", "course_id": str(self.course_id)}, {"body": "foo"}, + mock_is_forum_v2_enabled, mock_request ) - def test_create_thread_empty_title(self, mock_request): + def test_create_thread_empty_title(self, mock_is_forum_v2_enabled, mock_request): self._test_request_error( "create_thread", {"commentable_id": "dummy", "course_id": str(self.course_id)}, {"body": "foo", "title": " "}, + mock_is_forum_v2_enabled, mock_request ) - def test_create_thread_no_body(self, mock_request): + def test_create_thread_no_body(self, mock_is_forum_v2_enabled, mock_request): self._test_request_error( "create_thread", {"commentable_id": "dummy", "course_id": str(self.course_id)}, {"title": "foo"}, + mock_is_forum_v2_enabled, mock_request ) - def test_create_thread_empty_body(self, mock_request): + def test_create_thread_empty_body(self, mock_is_forum_v2_enabled, mock_request): self._test_request_error( "create_thread", {"commentable_id": "dummy", "course_id": str(self.course_id)}, {"body": " ", "title": "foo"}, + mock_is_forum_v2_enabled, mock_request ) - def test_update_thread_no_title(self, mock_request): + def test_update_thread_no_title(self, mock_is_forum_v2_enabled, mock_request): self._test_request_error( "update_thread", {"thread_id": "dummy", "course_id": str(self.course_id)}, {"body": "foo"}, + mock_is_forum_v2_enabled, mock_request ) - def test_update_thread_empty_title(self, mock_request): + def test_update_thread_empty_title(self, mock_is_forum_v2_enabled, mock_request): self._test_request_error( "update_thread", {"thread_id": "dummy", "course_id": str(self.course_id)}, {"body": "foo", "title": " "}, + mock_is_forum_v2_enabled, mock_request ) - def test_update_thread_no_body(self, mock_request): + def test_update_thread_no_body(self, mock_is_forum_v2_enabled, mock_request): self._test_request_error( "update_thread", {"thread_id": "dummy", "course_id": str(self.course_id)}, {"title": "foo"}, + mock_is_forum_v2_enabled, mock_request ) - def test_update_thread_empty_body(self, mock_request): + def test_update_thread_empty_body(self, mock_is_forum_v2_enabled, mock_request): self._test_request_error( "update_thread", {"thread_id": "dummy", "course_id": str(self.course_id)}, {"body": " ", "title": "foo"}, + mock_is_forum_v2_enabled, mock_request ) - def test_update_thread_course_topic(self, mock_request): + def test_update_thread_course_topic(self, mock_is_forum_v2_enabled, mock_request): with self.assert_discussion_signals('thread_edited'): - self.update_thread_helper(mock_request) + self.update_thread_helper(mock_is_forum_v2_enabled, mock_request) @patch( 'lms.djangoapps.discussion.django_comment_client.utils.get_discussion_categories_ids', return_value=["test_commentable"], ) - def test_update_thread_wrong_commentable_id(self, mock_get_discussion_id_map, mock_request): + def test_update_thread_wrong_commentable_id(self, mock_get_discussion_id_map, mock_is_forum_v2_enabled, mock_request): self._test_request_error( "update_thread", {"thread_id": "dummy", "course_id": str(self.course_id)}, {"body": "foo", "title": "foo", "commentable_id": "wrong_commentable"}, + mock_is_forum_v2_enabled, mock_request ) - def test_create_comment(self, mock_request): + def test_create_comment(self, mock_is_forum_v2_enabled, mock_request): + mock_is_forum_v2_enabled.return_value = False self._setup_mock_request(mock_request) with self.assert_discussion_signals('comment_created'): response = self.client.post( @@ -678,55 +738,62 @@ def test_create_comment(self, mock_request): ) assert response.status_code == 200 - def test_create_comment_no_body(self, mock_request): + def test_create_comment_no_body(self, mock_is_forum_v2_enabled, mock_request): self._test_request_error( "create_comment", {"thread_id": "dummy", "course_id": str(self.course_id)}, {}, + mock_is_forum_v2_enabled, mock_request ) - def test_create_comment_empty_body(self, mock_request): + def test_create_comment_empty_body(self, mock_is_forum_v2_enabled, mock_request): self._test_request_error( "create_comment", {"thread_id": "dummy", "course_id": str(self.course_id)}, {"body": " "}, + mock_is_forum_v2_enabled, mock_request ) - def test_create_sub_comment_no_body(self, mock_request): + def test_create_sub_comment_no_body(self, mock_is_forum_v2_enabled, mock_request): self._test_request_error( "create_sub_comment", {"comment_id": "dummy", "course_id": str(self.course_id)}, {}, + mock_is_forum_v2_enabled, mock_request ) - def test_create_sub_comment_empty_body(self, mock_request): + def test_create_sub_comment_empty_body(self, mock_is_forum_v2_enabled, mock_request): self._test_request_error( "create_sub_comment", {"comment_id": "dummy", "course_id": str(self.course_id)}, {"body": " "}, + mock_is_forum_v2_enabled, mock_request ) - def test_update_comment_no_body(self, mock_request): + def test_update_comment_no_body(self, mock_is_forum_v2_enabled, mock_request): self._test_request_error( "update_comment", {"comment_id": "dummy", "course_id": str(self.course_id)}, {}, + mock_is_forum_v2_enabled, mock_request ) - def test_update_comment_empty_body(self, mock_request): + def test_update_comment_empty_body(self, mock_is_forum_v2_enabled, mock_request): self._test_request_error( "update_comment", {"comment_id": "dummy", "course_id": str(self.course_id)}, {"body": " "}, + mock_is_forum_v2_enabled, mock_request ) - def test_update_comment_basic(self, mock_request): + def test_update_comment_basic(self, mock_is_forum_v2_enabled, mock_request): + mock_is_forum_v2_enabled.return_value = False self._setup_mock_request(mock_request) comment_id = "test_comment_id" updated_body = "updated body" @@ -748,13 +815,14 @@ def test_update_comment_basic(self, mock_request): data={"body": updated_body} ) - def test_flag_thread_open(self, mock_request): - self.flag_thread(mock_request, False) + def test_flag_thread_open(self, mock_is_forum_v2_enabled, mock_request): + self.flag_thread(mock_is_forum_v2_enabled, mock_request, False) - def test_flag_thread_close(self, mock_request): - self.flag_thread(mock_request, True) + def test_flag_thread_close(self, mock_is_forum_v2_enabled, mock_request): + self.flag_thread(mock_is_forum_v2_enabled, mock_request, True) - def flag_thread(self, mock_request, is_closed): + def flag_thread(self, mock_is_forum_v2_enabled, mock_request, is_closed): + mock_is_forum_v2_enabled.return_value = False self._set_mock_request_data(mock_request, { "title": "Hello", "body": "this is a post", @@ -826,13 +894,14 @@ def flag_thread(self, mock_request, is_closed): assert response.status_code == 200 - def test_un_flag_thread_open(self, mock_request): - self.un_flag_thread(mock_request, False) + def test_un_flag_thread_open(self, mock_is_forum_v2_enabled, mock_request): + self.un_flag_thread(mock_is_forum_v2_enabled, mock_request, False) - def test_un_flag_thread_close(self, mock_request): - self.un_flag_thread(mock_request, True) + def test_un_flag_thread_close(self, mock_is_forum_v2_enabled, mock_request): + self.un_flag_thread(mock_is_forum_v2_enabled, mock_request, True) - def un_flag_thread(self, mock_request, is_closed): + def un_flag_thread(self, mock_is_forum_v2_enabled, mock_request, is_closed): + mock_is_forum_v2_enabled.return_value = False self._set_mock_request_data(mock_request, { "title": "Hello", "body": "this is a post", @@ -905,13 +974,14 @@ def un_flag_thread(self, mock_request, is_closed): assert response.status_code == 200 - def test_flag_comment_open(self, mock_request): - self.flag_comment(mock_request, False) + def test_flag_comment_open(self, mock_is_forum_v2_enabled, mock_request): + self.flag_comment(mock_is_forum_v2_enabled, mock_request, False) - def test_flag_comment_close(self, mock_request): - self.flag_comment(mock_request, True) + def test_flag_comment_close(self, mock_is_forum_v2_enabled, mock_request): + self.flag_comment(mock_is_forum_v2_enabled, mock_request, True) - def flag_comment(self, mock_request, is_closed): + def flag_comment(self, mock_is_forum_v2_enabled, mock_request, is_closed): + mock_is_forum_v2_enabled.return_value = False self._set_mock_request_data(mock_request, { "body": "this is a comment", "course_id": "MITx/999/Robot_Super_Course", @@ -976,13 +1046,14 @@ def flag_comment(self, mock_request, is_closed): assert response.status_code == 200 - def test_un_flag_comment_open(self, mock_request): - self.un_flag_comment(mock_request, False) + def test_un_flag_comment_open(self, mock_is_forum_v2_enabled, mock_request): + self.un_flag_comment(mock_is_forum_v2_enabled, mock_request, False) - def test_un_flag_comment_close(self, mock_request): - self.un_flag_comment(mock_request, True) + def test_un_flag_comment_close(self, mock_is_forum_v2_enabled, mock_request): + self.un_flag_comment(mock_is_forum_v2_enabled, mock_request, True) - def un_flag_comment(self, mock_request, is_closed): + def un_flag_comment(self, mock_is_forum_v2_enabled, mock_request, is_closed): + mock_is_forum_v2_enabled.return_value = False self._set_mock_request_data(mock_request, { "body": "this is a comment", "course_id": "MITx/999/Robot_Super_Course", @@ -1054,7 +1125,8 @@ def un_flag_comment(self, mock_request, is_closed): ('downvote_comment', 'comment_id', 'comment_voted') ) @ddt.unpack - def test_voting(self, view_name, item_id, signal, mock_request): + def test_voting(self, view_name, item_id, signal, mock_is_forum_v2_enabled, mock_request): + mock_is_forum_v2_enabled.return_value = False self._setup_mock_request(mock_request) with self.assert_discussion_signals(signal): response = self.client.post( @@ -1065,7 +1137,8 @@ def test_voting(self, view_name, item_id, signal, mock_request): ) assert response.status_code == 200 - def test_endorse_comment(self, mock_request): + def test_endorse_comment(self, mock_is_forum_v2_enabled, mock_request): + mock_is_forum_v2_enabled.return_value = False self._setup_mock_request(mock_request) self.client.login(username=self.moderator.username, password=self.password) with self.assert_discussion_signals('comment_endorsed', user=self.moderator): @@ -1079,6 +1152,7 @@ def test_endorse_comment(self, mock_request): @patch("openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request", autospec=True) +@patch('openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', autospec=True) @disable_signal(views, 'comment_endorsed') class ViewPermissionsTestCase(ForumsEnableMixin, UrlResetMixin, SharedModuleStoreTestCase, MockRequestSetupMixin): @@ -1106,8 +1180,19 @@ def setUpTestData(cls): @patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) def setUp(self): super().setUp() + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment" + ) + self.mock_get_course_id_by_comment = patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) - def test_pin_thread_as_student(self, mock_request): + def test_pin_thread_as_student(self, mock_is_forum_v2_enabled, mock_request): + mock_is_forum_v2_enabled.return_value = False self._set_mock_request_data(mock_request, {}) self.client.login(username=self.student.username, password=self.password) response = self.client.post( @@ -1115,7 +1200,8 @@ def test_pin_thread_as_student(self, mock_request): ) assert response.status_code == 401 - def test_pin_thread_as_moderator(self, mock_request): + def test_pin_thread_as_moderator(self, mock_is_forum_v2_enabled, mock_request): + mock_is_forum_v2_enabled.return_value = False self._set_mock_request_data(mock_request, {}) self.client.login(username=self.moderator.username, password=self.password) response = self.client.post( @@ -1123,7 +1209,8 @@ def test_pin_thread_as_moderator(self, mock_request): ) assert response.status_code == 200 - def test_un_pin_thread_as_student(self, mock_request): + def test_un_pin_thread_as_student(self, mock_is_forum_v2_enabled, mock_request): + mock_is_forum_v2_enabled.return_value = False self._set_mock_request_data(mock_request, {}) self.client.login(username=self.student.username, password=self.password) response = self.client.post( @@ -1131,7 +1218,8 @@ def test_un_pin_thread_as_student(self, mock_request): ) assert response.status_code == 401 - def test_un_pin_thread_as_moderator(self, mock_request): + def test_un_pin_thread_as_moderator(self, mock_is_forum_v2_enabled, mock_request): + mock_is_forum_v2_enabled.return_value = False self._set_mock_request_data(mock_request, {}) self.client.login(username=self.moderator.username, password=self.password) response = self.client.post( @@ -1139,7 +1227,7 @@ def test_un_pin_thread_as_moderator(self, mock_request): ) assert response.status_code == 200 - def _set_mock_request_thread_and_comment(self, mock_request, thread_data, comment_data): + def _set_mock_request_thread_and_comment(self, mock_is_forum_v2_enabled, mock_request, thread_data, comment_data): def handle_request(*args, **kwargs): url = args[1] if "/threads/" in url: @@ -1148,10 +1236,12 @@ def handle_request(*args, **kwargs): return self._create_response_mock(comment_data) else: raise ArgumentError("Bad url to mock request") + mock_is_forum_v2_enabled.return_value = False mock_request.side_effect = handle_request - def test_endorse_response_as_staff(self, mock_request): + def test_endorse_response_as_staff(self, mock_is_forum_v2_enabled, mock_request): self._set_mock_request_thread_and_comment( + mock_is_forum_v2_enabled, mock_request, {"type": "thread", "thread_type": "question", "user_id": str(self.student.id), "commentable_id": "course"}, {"type": "comment", "thread_id": "dummy"} @@ -1162,8 +1252,9 @@ def test_endorse_response_as_staff(self, mock_request): ) assert response.status_code == 200 - def test_endorse_response_as_student(self, mock_request): + def test_endorse_response_as_student(self, mock_is_forum_v2_enabled, mock_request): self._set_mock_request_thread_and_comment( + mock_is_forum_v2_enabled, mock_request, {"type": "thread", "thread_type": "question", "user_id": str(self.moderator.id), "commentable_id": "course"}, @@ -1175,8 +1266,9 @@ def test_endorse_response_as_student(self, mock_request): ) assert response.status_code == 401 - def test_endorse_response_as_student_question_author(self, mock_request): + def test_endorse_response_as_student_question_author(self, mock_is_forum_v2_enabled, mock_request): self._set_mock_request_thread_and_comment( + mock_is_forum_v2_enabled, mock_request, {"type": "thread", "thread_type": "question", "user_id": str(self.student.id), "commentable_id": "course"}, {"type": "comment", "thread_id": "dummy"} @@ -1209,10 +1301,12 @@ def setUpTestData(cls): CourseEnrollmentFactory(user=cls.student, course_id=cls.course.id) @patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) - def _test_unicode_data(self, text, mock_request,): + @patch('openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', autospec=True) + def _test_unicode_data(self, text, mock_is_forum_v2_enabled, mock_request,): """ Test to make sure unicode data in a thread doesn't break it. """ + mock_is_forum_v2_enabled.return_value = False self._set_mock_request_data(mock_request, {}) request = RequestFactory().post("dummy_url", {"thread_type": "discussion", "body": text, "title": text}) request.user = self.student @@ -1235,6 +1329,13 @@ class UpdateThreadUnicodeTestCase( UnicodeTestMixin, MockRequestSetupMixin ): + def setUp(self): + super().setUp() + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) @classmethod def setUpClass(cls): @@ -1255,7 +1356,9 @@ def setUpTestData(cls): return_value=["test_commentable"], ) @patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) - def _test_unicode_data(self, text, mock_request, mock_get_discussion_id_map): + @patch('openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', autospec=True) + def _test_unicode_data(self, text, mock_is_forum_v2_enabled, mock_request, mock_get_discussion_id_map): + mock_is_forum_v2_enabled.return_value = False self._set_mock_request_data(mock_request, { "user_id": str(self.student.id), "closed": False, @@ -1280,6 +1383,13 @@ class CreateCommentUnicodeTestCase( UnicodeTestMixin, MockRequestSetupMixin ): + def setUp(self): + super().setUp() + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) @classmethod def setUpClass(cls): @@ -1296,7 +1406,9 @@ def setUpTestData(cls): CourseEnrollmentFactory(user=cls.student, course_id=cls.course.id) @patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) - def _test_unicode_data(self, text, mock_request): + @patch('openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', autospec=True) + def _test_unicode_data(self, text, mock_is_forum_v2_enabled, mock_request): + mock_is_forum_v2_enabled.return_value = False commentable_id = "non_team_dummy_id" self._set_mock_request_data(mock_request, { "closed": False, @@ -1327,6 +1439,13 @@ class UpdateCommentUnicodeTestCase( UnicodeTestMixin, MockRequestSetupMixin ): + def setUp(self): + super().setUp() + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment" + ) + self.mock_get_course_id_by_comment = patcher.start() + self.addCleanup(patcher.stop) @classmethod def setUpClass(cls): @@ -1343,7 +1462,9 @@ def setUpTestData(cls): CourseEnrollmentFactory(user=cls.student, course_id=cls.course.id) @patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) - def _test_unicode_data(self, text, mock_request): + @patch('openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', autospec=True) + def _test_unicode_data(self, text, mock_is_forum_v2_enabled, mock_request): + mock_is_forum_v2_enabled.return_value = False self._set_mock_request_data(mock_request, { "user_id": str(self.student.id), "closed": False, @@ -1359,6 +1480,7 @@ def _test_unicode_data(self, text, mock_request): @patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) +@patch('openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', autospec=True) class CommentActionTestCase( MockRequestSetupMixin, CohortedTestCase, @@ -1367,11 +1489,18 @@ class CommentActionTestCase( def call_view( self, view_name, + mock_is_forum_v2_enabled, mock_request, user=None, post_params=None, view_args=None ): + mock_is_forum_v2_enabled.return_value = False + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment" + ) + self.mock_get_course_id_by_comment = patcher.start() + self.addCleanup(patcher.stop) self._set_mock_request_data( mock_request, { @@ -1394,9 +1523,9 @@ def call_view( **(view_args or {}) ) - def test_flag(self, mock_request): + def test_flag(self, mock_is_forum_v2_enabled, mock_request): with mock.patch('openedx.core.djangoapps.django_comment_common.signals.comment_flagged.send') as signal_mock: - self.call_view("flag_abuse_for_comment", mock_request) + self.call_view("flag_abuse_for_comment", mock_is_forum_v2_enabled, mock_request) self.assertEqual(signal_mock.call_count, 1) @@ -1410,6 +1539,14 @@ class CreateSubCommentUnicodeTestCase( """ Make sure comments under a response can handle unicode. """ + def setUp(self): + super().setUp() + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment" + ) + self.mock_get_course_id_by_comment = patcher.start() + self.addCleanup(patcher.stop) + @classmethod def setUpClass(cls): # pylint: disable=super-method-not-called @@ -1425,10 +1562,12 @@ def setUpTestData(cls): CourseEnrollmentFactory(user=cls.student, course_id=cls.course.id) @patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) - def _test_unicode_data(self, text, mock_request): + @patch('openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', autospec=True) + def _test_unicode_data(self, text, mock_is_forum_v2_enabled, mock_request): """ Create a comment with unicode in it. """ + mock_is_forum_v2_enabled.return_value = False self._set_mock_request_data(mock_request, { "closed": False, "depth": 1, @@ -1453,6 +1592,7 @@ def _test_unicode_data(self, text, mock_request): @ddt.ddt @patch("openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request", autospec=True) +@patch('openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', autospec=True) @disable_signal(views, 'thread_voted') @disable_signal(views, 'thread_edited') @disable_signal(views, 'comment_created') @@ -1562,13 +1702,24 @@ def create_users_and_enroll(coursemode): users=[cls.group_moderator, cls.cohorted] ) - @patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) + @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) def setUp(self): super().setUp() - def _setup_mock(self, user, mock_request, data): + def _setup_mock(self, user, mock_is_forum_v2_enabled, mock_request, data): user = getattr(self, user) + mock_is_forum_v2_enabled.return_value = False self._set_mock_request_data(mock_request, data) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment" + ) + self.mock_get_course_id_by_comment = patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) self.client.login(username=user.username, password=self.password) @ddt.data( @@ -1593,7 +1744,7 @@ def _setup_mock(self, user, mock_request, data): ('group_moderator', 'cohorted', 'course_commentable_id', 401, CourseDiscussionSettings.NONE) ) @ddt.unpack - def test_update_thread(self, user, thread_author, commentable_id, status_code, division_scheme, mock_request): + def test_update_thread(self, user, thread_author, commentable_id, status_code, division_scheme, mock_is_forum_v2_enabled, mock_request): """ Verify that update_thread is limited to thread authors and privileged users (team membership does not matter). """ @@ -1603,7 +1754,7 @@ def test_update_thread(self, user, thread_author, commentable_id, status_code, d thread_author = getattr(self, thread_author) self._setup_mock( - user, mock_request, # user is the person making the request. + user, mock_is_forum_v2_enabled, mock_request, # user is the person making the request. { "user_id": str(thread_author.id), "closed": False, "commentable_id": commentable_id, @@ -1643,12 +1794,12 @@ def test_update_thread(self, user, thread_author, commentable_id, status_code, d ('group_moderator', 'cohorted', 'team_commentable_id', 401, CourseDiscussionSettings.NONE) ) @ddt.unpack - def test_delete_comment(self, user, comment_author, commentable_id, status_code, division_scheme, mock_request): + def test_delete_comment(self, user, comment_author, commentable_id, status_code, division_scheme, mock_is_forum_v2_enabled, mock_request): commentable_id = getattr(self, commentable_id) comment_author = getattr(self, comment_author) self.change_divided_discussion_settings(division_scheme) - self._setup_mock(user, mock_request, { + self._setup_mock(user, mock_is_forum_v2_enabled, mock_request, { "closed": False, "commentable_id": commentable_id, "user_id": str(comment_author.id), @@ -1671,12 +1822,12 @@ def test_delete_comment(self, user, comment_author, commentable_id, status_code, @ddt.data(*ddt_permissions_args) @ddt.unpack - def test_create_comment(self, user, commentable_id, status_code, mock_request): + def test_create_comment(self, user, commentable_id, status_code, mock_is_forum_v2_enabled, 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}) + self._setup_mock(user, mock_is_forum_v2_enabled, mock_request, {"closed": False, "commentable_id": commentable_id}) response = self.client.post( reverse( @@ -1692,13 +1843,13 @@ def test_create_comment(self, user, commentable_id, status_code, mock_request): @ddt.data(*ddt_permissions_args) @ddt.unpack - def test_create_sub_comment(self, user, commentable_id, status_code, mock_request): + def test_create_sub_comment(self, user, commentable_id, status_code, mock_is_forum_v2_enabled, 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, + user, mock_is_forum_v2_enabled, mock_request, {"closed": False, "commentable_id": commentable_id, "thread_id": "dummy_thread"}, ) response = self.client.post( @@ -1715,14 +1866,14 @@ def test_create_sub_comment(self, user, commentable_id, status_code, mock_reques @ddt.data(*ddt_permissions_args) @ddt.unpack - def test_comment_actions(self, user, commentable_id, status_code, mock_request): + def test_comment_actions(self, user, commentable_id, status_code, mock_is_forum_v2_enabled, 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, + user, mock_is_forum_v2_enabled, mock_request, { "closed": False, "commentable_id": commentable_id, @@ -1742,14 +1893,14 @@ def test_comment_actions(self, user, commentable_id, status_code, mock_request): @ddt.data(*ddt_permissions_args) @ddt.unpack - def test_threads_actions(self, user, commentable_id, status_code, mock_request): + def test_threads_actions(self, user, commentable_id, status_code, mock_is_forum_v2_enabled, 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, + user, mock_is_forum_v2_enabled, mock_request, {"closed": False, "commentable_id": commentable_id, "body": "dummy body", "course_id": str(self.course.id)} ) for action in ["upvote_thread", "downvote_thread", "un_flag_abuse_for_thread", "flag_abuse_for_thread", @@ -1772,6 +1923,19 @@ class ForumEventTestCase(ForumsEnableMixin, SharedModuleStoreTestCase, MockReque """ Forum actions are expected to launch analytics events. Test these here. """ + def setUp(self): + super().setUp() + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment" + ) + self.mock_get_course_id_by_comment = patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) + @classmethod def setUpClass(cls): # pylint: disable=super-method-not-called @@ -1791,12 +1955,14 @@ def setUpTestData(cls): @patch('eventtracking.tracker.emit') @patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) - def test_response_event(self, mock_request, mock_emit): + @patch('openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', autospec=True) + def test_response_event(self, mock_is_forum_v2_enabled, mock_request, mock_emit): """ Check to make sure an event is fired when a user responds to a thread. """ event_receiver = Mock() FORUM_THREAD_RESPONSE_CREATED.connect(event_receiver) + mock_is_forum_v2_enabled.return_value = False self._set_mock_request_data(mock_request, { "closed": False, "commentable_id": 'test_commentable_id', @@ -1833,12 +1999,14 @@ def test_response_event(self, mock_request, mock_emit): @patch('eventtracking.tracker.emit') @patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) - def test_comment_event(self, mock_request, mock_emit): + @patch('openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', autospec=True) + def test_comment_event(self, mock_is_forum_v2_enabled, mock_request, mock_emit): """ Ensure an event is fired when someone comments on a response. """ event_receiver = Mock() FORUM_RESPONSE_COMMENT_CREATED.connect(event_receiver) + mock_is_forum_v2_enabled.return_value = False self._set_mock_request_data(mock_request, { "closed": False, "depth": 1, @@ -1875,6 +2043,7 @@ def test_comment_event(self, mock_request, mock_emit): @patch('eventtracking.tracker.emit') @patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) + @patch('openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', autospec=True) @ddt.data(( 'create_thread', 'edx.forum.thread.created', { @@ -1896,7 +2065,7 @@ def test_comment_event(self, mock_request, mock_emit): {'comment_id': 'dummy_comment_id'} )) @ddt.unpack - def test_team_events(self, view_name, event_name, view_data, view_kwargs, mock_request, mock_emit): + def test_team_events(self, view_name, event_name, view_data, view_kwargs, mock_is_forum_v2_enabled, mock_request, mock_emit): user = self.student team = CourseTeamFactory.create(discussion_topic_id=TEAM_COMMENTABLE_ID) CourseTeamMembershipFactory.create(team=team, user=user) @@ -1905,6 +2074,7 @@ def test_team_events(self, view_name, event_name, view_data, view_kwargs, mock_r forum_event = views.TRACKING_LOG_TO_EVENT_MAPS.get(event_name) forum_event.connect(event_receiver) + mock_is_forum_v2_enabled.return_value = False self._set_mock_request_data(mock_request, { 'closed': False, 'commentable_id': TEAM_COMMENTABLE_ID, @@ -1943,9 +2113,11 @@ def test_team_events(self, view_name, event_name, view_data, view_kwargs, mock_r @ddt.unpack @patch('eventtracking.tracker.emit') @patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) - def test_thread_voted_event(self, view_name, obj_id_name, obj_type, mock_request, mock_emit): + @patch('openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', autospec=True) + def test_thread_voted_event(self, view_name, obj_id_name, obj_type, mock_is_forum_v2_enabled, mock_request, mock_emit): undo = view_name.startswith('undo') + mock_is_forum_v2_enabled.return_value = False self._set_mock_request_data(mock_request, { 'closed': False, 'commentable_id': 'test_commentable_id', @@ -1971,11 +2143,13 @@ def test_thread_voted_event(self, view_name, obj_id_name, obj_type, mock_request @ddt.data('follow_thread', 'unfollow_thread',) @patch('eventtracking.tracker.emit') @patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) - def test_thread_followed_event(self, view_name, mock_request, mock_emit): + @patch('openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', autospec=True) + def test_thread_followed_event(self, view_name, mock_is_forum_v2_enabled, mock_request, mock_emit): event_receiver = Mock() for signal in views.TRACKING_LOG_TO_EVENT_MAPS.values(): signal.connect(event_receiver) + mock_is_forum_v2_enabled.return_value = False self._set_mock_request_data(mock_request, { 'closed': False, 'commentable_id': 'test_commentable_id', @@ -2025,10 +2199,11 @@ def setUpTestData(cls): cls.other_user = UserFactory.create(username="other") CourseEnrollmentFactory(user=cls.other_user, course_id=cls.course.id) - def set_post_counts(self, mock_request, threads_count=1, comments_count=1): + def set_post_counts(self, mock_is_forum_v2_enabled, mock_request, threads_count=1, comments_count=1): """ sets up a mock response from the comments service for getting post counts for our other_user """ + mock_is_forum_v2_enabled.return_value = False self._set_mock_request_data(mock_request, { "threads_count": threads_count, "comments_count": comments_count, @@ -2042,15 +2217,17 @@ def make_request(self, method='get', course_id=None, **kwargs): return views.users(request, course_id=str(course_id)) @patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) - def test_finds_exact_match(self, mock_request): - self.set_post_counts(mock_request) + @patch('openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', autospec=True) + def test_finds_exact_match(self, mock_is_forum_v2_enabled, mock_request): + self.set_post_counts(mock_is_forum_v2_enabled, mock_request) response = self.make_request(username="other") assert response.status_code == 200 assert json.loads(response.content.decode('utf-8'))['users'] == [{'id': self.other_user.id, 'username': self.other_user.username}] @patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) - def test_finds_no_match(self, mock_request): - self.set_post_counts(mock_request) + @patch('openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', autospec=True) + def test_finds_no_match(self, mock_is_forum_v2_enabled, mock_request): + self.set_post_counts(mock_is_forum_v2_enabled, mock_request) response = self.make_request(username="othor") assert response.status_code == 200 assert json.loads(response.content.decode('utf-8'))['users'] == [] @@ -2086,8 +2263,9 @@ def test_requires_requestor_enrolled_in_course(self): assert 'users' not in content @patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) - def test_requires_matched_user_has_forum_content(self, mock_request): - self.set_post_counts(mock_request, 0, 0) + @patch('openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', autospec=True) + def test_requires_matched_user_has_forum_content(self, mock_is_forum_v2_enabled, mock_request): + self.set_post_counts(mock_is_forum_v2_enabled, mock_request, 0, 0) response = self.make_request(username="other") assert response.status_code == 200 assert json.loads(response.content.decode('utf-8'))['users'] == [] diff --git a/lms/djangoapps/discussion/django_comment_client/base/tests_v2.py b/lms/djangoapps/discussion/django_comment_client/base/tests_v2.py new file mode 100644 index 000000000000..95de4d57b811 --- /dev/null +++ b/lms/djangoapps/discussion/django_comment_client/base/tests_v2.py @@ -0,0 +1,259 @@ +import pytest + +# pylint: skip-file +"""Tests for django forum V2.""" + + +import logging +from unittest import mock +from unittest.mock import patch + +import ddt +from django.core.management import call_command +from django.test.client import RequestFactory +from opaque_keys.edx.locator import CourseLocator +from common.djangoapps.util.testing import UrlResetMixin +from common.test.utils import MockSignalHandlerMixin, disable_signal +from lms.djangoapps.discussion.django_comment_client.base import views +from lms.djangoapps.discussion.django_comment_client.tests.group_id import ( + GroupIdAssertionMixin, +) +from lms.djangoapps.discussion.django_comment_client.tests.utils import ( + CohortedTestCase, + ForumsEnableMixin, +) +from xmodule.modulestore.tests.django_utils import ( + SharedModuleStoreTestCase, +) +from xmodule.modulestore.tests.factories import CourseFactory + +log = logging.getLogger(__name__) + +CS_PREFIX = "http://localhost:4567/api/v1" + + +@patch( + "openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled", + autospec=True, +) +@disable_signal(views, "thread_edited") +@disable_signal(views, "thread_voted") +@disable_signal(views, "thread_deleted") +class ThreadActionGroupIdTestCase(CohortedTestCase, GroupIdAssertionMixin): + + def _get_mocked_instance_from_view_name(self, view_name): + """ + Get the relavent Mock function based on the view_name + """ + mocks = { + "create_thread": self.mock_create_thread, + "get_thread": self.mock_get_thread, + "update_thread": self.mock_update_thread, + } + return mocks.get(view_name) + + def setUp(self): + super().setUp() + self.mock_create_thread = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.create_thread", + autospec=True, + ).start() + self.mock_get_thread = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_thread", + autospec=True, + ).start() + self.mock_update_thread = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.update_thread", + autospec=True, + ).start() + self.mock_update_thread_flag = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.update_thread_flag", + autospec=True, + ).start() + default_response = { + "user_id": str(self.student.id), + "group_id": self.student_cohort.id, + "closed": False, + "type": "thread", + "commentable_id": "non_team_dummy_id", + "body": "test body", + } + self.mock_create_thread.return_value = default_response + self.mock_get_thread.return_value = default_response + self.mock_update_thread.return_value = default_response + self.mock_update_thread_flag.return_value = default_response + + self.mock_get_course_id_by_thread = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread", + autospec=True, + ).start() + self.mock_get_course_id_by_thread.return_value = CourseLocator( + "dummy", "test_123", "test_run" + ) + + self.addCleanup(mock.patch.stopall) # Ensure all mocks are stopped after tests + + def call_view( + self, + view_name, + mock_is_forum_v2_enabled, + user=None, + post_params=None, + view_args=None, + ): + mocked_view = self._get_mocked_instance_from_view_name(view_name) + mock_is_forum_v2_enabled.return_value = True + if mocked_view: + mocked_view.return_value = { + "user_id": str(self.student.id), + "group_id": self.student_cohort.id, + "closed": False, + "type": "thread", + "commentable_id": "non_team_dummy_id", + "body": "test body", + } + request = RequestFactory().post("dummy_url", post_params or {}) + request.user = user or self.student + request.view_name = view_name + + return getattr(views, view_name)( + request, + course_id=str(self.course.id), + thread_id="dummy", + **(view_args or {}), + ) + + def test_flag(self, mock_is_forum_v2_enabled): + with mock.patch( + "openedx.core.djangoapps.django_comment_common.signals.thread_flagged.send" + ) as signal_mock: + response = self.call_view("flag_abuse_for_thread", mock_is_forum_v2_enabled) + self._assert_json_response_contains_group_info(response) + self.assertEqual(signal_mock.call_count, 1) + response = self.call_view("un_flag_abuse_for_thread", mock_is_forum_v2_enabled) + self._assert_json_response_contains_group_info(response) + + +@ddt.ddt +@disable_signal(views, "comment_flagged") +@disable_signal(views, "thread_flagged") +@patch( + "openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled", + autospec=True, +) +class ViewsTestCase( + CohortedTestCase, + ForumsEnableMixin, + UrlResetMixin, + SharedModuleStoreTestCase, + MockSignalHandlerMixin, +): + + @classmethod + def setUpClass(cls): + # pylint: disable=super-method-not-called + with super().setUpClassAndTestData(): + cls.course = CourseFactory.create( + org="MITx", + course="999", + discussion_topics={"Some Topic": {"id": "some_topic"}}, + display_name="Robot Super Course", + ) + + @classmethod + def setUpTestData(cls): + super().setUpTestData() + + cls.course_id = cls.course.id + + # seed the forums permissions and roles + call_command("seed_permissions_roles", str(cls.course_id)) + + @patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) + def setUp(self): + # Patching the ENABLE_DISCUSSION_SERVICE value affects the contents of urls.py, + # so we need to call super.setUp() which reloads urls.py (because + # of the UrlResetMixin) + super().setUp() + self.mock_get_course_id_by_comment = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment" + ).start() + self.mock_get_course_id_by_comment.return_value = self.course.id + self.mock_get_course_id_by_thread = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ).start() + self.mock_get_course_id_by_thread.return_value = self.course.id + self.mock_create_thread = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.create_thread", + autospec=True, + ).start() + self.mock_get_thread = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_thread", + autospec=True, + ).start() + self.mock_update_thread = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.update_thread", + autospec=True, + ).start() + self.mock_update_thread_flag = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.update_thread_flag", + autospec=True, + ).start() + self.mock_update_comment_flag = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.update_comment_flag", + autospec=True, + ).start() + default_response = { + "user_id": str(self.student.id), + "group_id": self.student_cohort.id, + "closed": False, + "type": "thread", + "commentable_id": "non_team_dummy_id", + "body": "test body", + } + self.mock_create_thread.return_value = default_response + self.mock_get_thread.return_value = default_response + self.mock_update_thread.return_value = default_response + self.mock_update_thread_flag.return_value = default_response + self.mock_update_comment_flag.return_value = default_response + self.addCleanup(mock.patch.stopall) + + def test_flag_thread(self, mock_is_forum_v2_enabled): + mock_is_forum_v2_enabled.return_value = True + self.client.put( + f"{CS_PREFIX}/threads/518d4237b023791dca00000d/abuse_flag", + kwargs={ + "thread_id": "518d4237b023791dca00000d", + "course_id": str(self.course_id), + }, + ) + + def test_un_flag_thread(self, mock_is_forum_v2_enabled): + mock_is_forum_v2_enabled.return_value = True + self.client.put( + f"{CS_PREFIX}/threads/518d4237b023791dca00000d/abuse_unflag", + kwargs={ + "thread_id": "518d4237b023791dca00000d", + "course_id": str(self.course_id), + }, + ) + + def test_flag_comment(self, mock_is_forum_v2_enabled): + mock_is_forum_v2_enabled.return_value = True + self.client.put( + f"{CS_PREFIX}/comments/518d4237b023791dca00000d/abuse_flag", + kwargs={ + "comment_id": "518d4237b023791dca00000d", + "course_id": str(self.course_id), + }, + ) + + def test_un_flag_comment(self, mock_is_forum_v2_enabled): + mock_is_forum_v2_enabled.return_value = True + self.client.put( + f"{CS_PREFIX}/comments/518d4237b023791dca00000d/abuse_unflag", + kwargs={ + "comment_id": "518d4237b023791dca00000d", + "course_id": str(self.course_id), + }, + ) diff --git a/lms/djangoapps/discussion/django_comment_client/base/views.py b/lms/djangoapps/discussion/django_comment_client/base/views.py index e3e52a5400a4..3df362bdf6d2 100644 --- a/lms/djangoapps/discussion/django_comment_client/base/views.py +++ b/lms/djangoapps/discussion/django_comment_client/base/views.py @@ -562,7 +562,6 @@ def create_thread(request, course_id, commentable_id): params['context'] = ThreadContext.STANDALONE else: params['context'] = ThreadContext.COURSE - thread = cc.Thread(**params) # Divide the thread if required diff --git a/lms/djangoapps/discussion/django_comment_client/tests/group_id.py b/lms/djangoapps/discussion/django_comment_client/tests/group_id.py index 78853293ec46..0a5fbe491930 100644 --- a/lms/djangoapps/discussion/django_comment_client/tests/group_id.py +++ b/lms/djangoapps/discussion/django_comment_client/tests/group_id.py @@ -60,51 +60,76 @@ class CohortedTopicGroupIdTestMixin(GroupIdAssertionMixin): Provides test cases to verify that views pass the correct `group_id` to the comments service when requesting content in cohorted discussions. """ - def call_view(self, mock_request, commentable_id, user, group_id, pass_group_id=True): + def call_view(self, mock_is_forum_v2_enabled, mock_request, commentable_id, user, group_id, pass_group_id=True): """ Call the view for the implementing test class, constructing a request from the parameters. """ pass # lint-amnesty, pylint: disable=unnecessary-pass - def test_cohorted_topic_student_without_group_id(self, mock_request): - self.call_view(mock_request, "cohorted_topic", self.student, '', pass_group_id=False) + def test_cohorted_topic_student_without_group_id(self, mock_is_forum_v2_enabled, mock_request): + self.call_view(mock_is_forum_v2_enabled, mock_request, "cohorted_topic", self.student, '', pass_group_id=False) self._assert_comments_service_called_with_group_id(mock_request, self.student_cohort.id) - def test_cohorted_topic_student_none_group_id(self, mock_request): - self.call_view(mock_request, "cohorted_topic", self.student, "") + def test_cohorted_topic_student_none_group_id(self, mock_is_forum_v2_enabled, mock_request): + self.call_view(mock_is_forum_v2_enabled, mock_request, "cohorted_topic", self.student, "") self._assert_comments_service_called_with_group_id(mock_request, self.student_cohort.id) - def test_cohorted_topic_student_with_own_group_id(self, mock_request): - self.call_view(mock_request, "cohorted_topic", self.student, self.student_cohort.id) + def test_cohorted_topic_student_with_own_group_id(self, mock_is_forum_v2_enabled, mock_request): + self.call_view(mock_is_forum_v2_enabled, mock_request, "cohorted_topic", self.student, self.student_cohort.id) self._assert_comments_service_called_with_group_id(mock_request, self.student_cohort.id) - def test_cohorted_topic_student_with_other_group_id(self, mock_request): - self.call_view(mock_request, "cohorted_topic", self.student, self.moderator_cohort.id) + def test_cohorted_topic_student_with_other_group_id(self, mock_is_forum_v2_enabled, mock_request): + self.call_view( + mock_is_forum_v2_enabled, + mock_request, + "cohorted_topic", + self.student, + self.moderator_cohort.id + ) self._assert_comments_service_called_with_group_id(mock_request, self.student_cohort.id) - def test_cohorted_topic_moderator_without_group_id(self, mock_request): - self.call_view(mock_request, "cohorted_topic", self.moderator, '', pass_group_id=False) + def test_cohorted_topic_moderator_without_group_id(self, mock_is_forum_v2_enabled, mock_request): + self.call_view( + mock_is_forum_v2_enabled, + mock_request, + "cohorted_topic", + self.moderator, + '', + pass_group_id=False + ) self._assert_comments_service_called_without_group_id(mock_request) - def test_cohorted_topic_moderator_none_group_id(self, mock_request): - self.call_view(mock_request, "cohorted_topic", self.moderator, "") + def test_cohorted_topic_moderator_none_group_id(self, mock_is_forum_v2_enabled, mock_request): + self.call_view(mock_is_forum_v2_enabled, mock_request, "cohorted_topic", self.moderator, "") self._assert_comments_service_called_without_group_id(mock_request) - def test_cohorted_topic_moderator_with_own_group_id(self, mock_request): - self.call_view(mock_request, "cohorted_topic", self.moderator, self.moderator_cohort.id) + def test_cohorted_topic_moderator_with_own_group_id(self, mock_is_forum_v2_enabled, mock_request): + self.call_view( + mock_is_forum_v2_enabled, + mock_request, + "cohorted_topic", + self.moderator, + self.moderator_cohort.id + ) self._assert_comments_service_called_with_group_id(mock_request, self.moderator_cohort.id) - def test_cohorted_topic_moderator_with_other_group_id(self, mock_request): - self.call_view(mock_request, "cohorted_topic", self.moderator, self.student_cohort.id) + def test_cohorted_topic_moderator_with_other_group_id(self, mock_is_forum_v2_enabled, mock_request): + self.call_view( + mock_is_forum_v2_enabled, + mock_request, + "cohorted_topic", + self.moderator, + self.student_cohort.id + ) self._assert_comments_service_called_with_group_id(mock_request, self.student_cohort.id) - def test_cohorted_topic_moderator_with_invalid_group_id(self, mock_request): + def test_cohorted_topic_moderator_with_invalid_group_id(self, mock_is_forum_v2_enabled, mock_request): invalid_id = self.student_cohort.id + self.moderator_cohort.id - response = self.call_view(mock_request, "cohorted_topic", self.moderator, invalid_id) # lint-amnesty, pylint: disable=assignment-from-no-return + response = self.call_view(mock_is_forum_v2_enabled, mock_request, "cohorted_topic", self.moderator, invalid_id) # lint-amnesty, pylint: disable=assignment-from-no-return assert response.status_code == 500 - def test_cohorted_topic_enrollment_track_invalid_group_id(self, mock_request): + def test_cohorted_topic_enrollment_track_invalid_group_id(self, mock_is_forum_v2_enabled, mock_request): CourseModeFactory.create(course_id=self.course.id, mode_slug=CourseMode.AUDIT) CourseModeFactory.create(course_id=self.course.id, mode_slug=CourseMode.VERIFIED) discussion_settings = CourseDiscussionSettings.get(self.course.id) @@ -115,7 +140,7 @@ def test_cohorted_topic_enrollment_track_invalid_group_id(self, mock_request): }) invalid_id = -1000 - response = self.call_view(mock_request, "cohorted_topic", self.moderator, invalid_id) # lint-amnesty, pylint: disable=assignment-from-no-return + response = self.call_view(mock_is_forum_v2_enabled, mock_request, "cohorted_topic", self.moderator, invalid_id) # lint-amnesty, pylint: disable=assignment-from-no-return assert response.status_code == 500 @@ -124,57 +149,95 @@ class NonCohortedTopicGroupIdTestMixin(GroupIdAssertionMixin): Provides test cases to verify that views pass the correct `group_id` to the comments service when requesting content in non-cohorted discussions. """ - def call_view(self, mock_request, commentable_id, user, group_id, pass_group_id=True): + def call_view(self, mock_is_forum_v2_enabled, mock_request, commentable_id, user, group_id, pass_group_id=True): """ Call the view for the implementing test class, constructing a request from the parameters. """ pass # lint-amnesty, pylint: disable=unnecessary-pass - def test_non_cohorted_topic_student_without_group_id(self, mock_request): - self.call_view(mock_request, "non_cohorted_topic", self.student, '', pass_group_id=False) + def test_non_cohorted_topic_student_without_group_id(self, mock_is_forum_v2_enabled, mock_request): + self.call_view( + mock_is_forum_v2_enabled, + mock_request, + "non_cohorted_topic", + self.student, + '', + pass_group_id=False + ) self._assert_comments_service_called_without_group_id(mock_request) - def test_non_cohorted_topic_student_none_group_id(self, mock_request): - self.call_view(mock_request, "non_cohorted_topic", self.student, '') + def test_non_cohorted_topic_student_none_group_id(self, mock_is_forum_v2_enabled, mock_request): + self.call_view(mock_is_forum_v2_enabled, mock_request, "non_cohorted_topic", self.student, '') self._assert_comments_service_called_without_group_id(mock_request) - def test_non_cohorted_topic_student_with_own_group_id(self, mock_request): - self.call_view(mock_request, "non_cohorted_topic", self.student, self.student_cohort.id) + def test_non_cohorted_topic_student_with_own_group_id(self, mock_is_forum_v2_enabled, mock_request): + self.call_view( + mock_is_forum_v2_enabled, + mock_request, + "non_cohorted_topic", + self.student, + self.student_cohort.id + ) self._assert_comments_service_called_without_group_id(mock_request) - def test_non_cohorted_topic_student_with_other_group_id(self, mock_request): - self.call_view(mock_request, "non_cohorted_topic", self.student, self.moderator_cohort.id) + def test_non_cohorted_topic_student_with_other_group_id(self, mock_is_forum_v2_enabled, mock_request): + self.call_view( + mock_is_forum_v2_enabled, + mock_request, + "non_cohorted_topic", + self.student, + self.moderator_cohort.id + ) self._assert_comments_service_called_without_group_id(mock_request) - def test_non_cohorted_topic_moderator_without_group_id(self, mock_request): - self.call_view(mock_request, "non_cohorted_topic", self.moderator, '', pass_group_id=False) + def test_non_cohorted_topic_moderator_without_group_id(self, mock_is_forum_v2_enabled, mock_request): + self.call_view( + mock_is_forum_v2_enabled, + mock_request, + "non_cohorted_topic", + self.moderator, + "", + pass_group_id=False, + ) self._assert_comments_service_called_without_group_id(mock_request) - def test_non_cohorted_topic_moderator_none_group_id(self, mock_request): - self.call_view(mock_request, "non_cohorted_topic", self.moderator, '') + def test_non_cohorted_topic_moderator_none_group_id(self, mock_is_forum_v2_enabled, mock_request): + self.call_view(mock_is_forum_v2_enabled, mock_request, "non_cohorted_topic", self.moderator, '') self._assert_comments_service_called_without_group_id(mock_request) - def test_non_cohorted_topic_moderator_with_own_group_id(self, mock_request): - self.call_view(mock_request, "non_cohorted_topic", self.moderator, self.moderator_cohort.id) + def test_non_cohorted_topic_moderator_with_own_group_id(self, mock_is_forum_v2_enabled, mock_request): + self.call_view( + mock_is_forum_v2_enabled, + mock_request, + "non_cohorted_topic", + self.moderator, + self.moderator_cohort.id, + ) self._assert_comments_service_called_without_group_id(mock_request) - def test_non_cohorted_topic_moderator_with_other_group_id(self, mock_request): - self.call_view(mock_request, "non_cohorted_topic", self.moderator, self.student_cohort.id) + def test_non_cohorted_topic_moderator_with_other_group_id(self, mock_is_forum_v2_enabled, mock_request): + self.call_view( + mock_is_forum_v2_enabled, + mock_request, + "non_cohorted_topic", + self.moderator, + self.student_cohort.id, + ) self._assert_comments_service_called_without_group_id(mock_request) - def test_non_cohorted_topic_moderator_with_invalid_group_id(self, mock_request): + def test_non_cohorted_topic_moderator_with_invalid_group_id(self, mock_is_forum_v2_enabled, mock_request): invalid_id = self.student_cohort.id + self.moderator_cohort.id - self.call_view(mock_request, "non_cohorted_topic", self.moderator, invalid_id) + self.call_view(mock_is_forum_v2_enabled, mock_request, "non_cohorted_topic", self.moderator, invalid_id) self._assert_comments_service_called_without_group_id(mock_request) - def test_team_discussion_id_not_cohorted(self, mock_request): + def test_team_discussion_id_not_cohorted(self, mock_is_forum_v2_enabled, mock_request): team = CourseTeamFactory( course_id=self.course.id, topic_id='topic-id' ) team.add_user(self.student) - self.call_view(mock_request, team.discussion_topic_id, self.student, '') + self.call_view(mock_is_forum_v2_enabled, mock_request, team.discussion_topic_id, self.student, '') self._assert_comments_service_called_without_group_id(mock_request) diff --git a/lms/djangoapps/discussion/rest_api/api.py b/lms/djangoapps/discussion/rest_api/api.py index 19ccf26d19a4..a517e00dff34 100644 --- a/lms/djangoapps/discussion/rest_api/api.py +++ b/lms/djangoapps/discussion/rest_api/api.py @@ -199,7 +199,7 @@ def _get_course(course_key: CourseKey, user: User, check_tab: bool = True) -> Co return course -def _get_thread_and_context(request, thread_id, retrieve_kwargs=None): +def _get_thread_and_context(request, thread_id, retrieve_kwargs=None, course_id=None): """ Retrieve the given thread and build a serializer context for it, returning both. This function also enforces access control for the thread (checking @@ -213,7 +213,7 @@ def _get_thread_and_context(request, thread_id, retrieve_kwargs=None): retrieve_kwargs["with_responses"] = False if "mark_as_read" not in retrieve_kwargs: retrieve_kwargs["mark_as_read"] = False - cc_thread = Thread(id=thread_id).retrieve(**retrieve_kwargs) + cc_thread = Thread(id=thread_id).retrieve(course_id=course_id, **retrieve_kwargs) course_key = CourseKey.from_string(cc_thread["course_id"]) course = _get_course(course_key, request.user) context = get_context(course, request, cc_thread) @@ -1645,7 +1645,8 @@ def get_thread(request, thread_id, requested_fields=None, course_id=None): retrieve_kwargs={ "with_responses": True, "user_id": str(request.user.id), - } + }, + course_id=course_id, ) if course_id and course_id != cc_thread.course_id: raise ThreadNotFoundError("Thread not found.") diff --git a/lms/djangoapps/discussion/rest_api/discussions_notifications.py b/lms/djangoapps/discussion/rest_api/discussions_notifications.py index b0eb7c89dcab..a1c24bd397c9 100644 --- a/lms/djangoapps/discussion/rest_api/discussions_notifications.py +++ b/lms/djangoapps/discussion/rest_api/discussions_notifications.py @@ -202,7 +202,7 @@ def send_response_on_followed_post_notification(self): while has_more_subscribers: - subscribers = Subscription.fetch(self.thread.id, query_params={'page': page}) + subscribers = Subscription.fetch(self.thread.id, self.course.id, query_params={'page': page}) if page <= subscribers.num_pages: for subscriber in subscribers.collection: # Check if the subscriber is not the thread creator or response creator diff --git a/lms/djangoapps/discussion/rest_api/serializers.py b/lms/djangoapps/discussion/rest_api/serializers.py index f8868cbed8c8..ff0c656baf28 100644 --- a/lms/djangoapps/discussion/rest_api/serializers.py +++ b/lms/djangoapps/discussion/rest_api/serializers.py @@ -68,7 +68,7 @@ def get_context(course, request, thread=None): moderator_user_ids = get_moderator_users_list(course.id) ta_user_ids = get_course_ta_users_list(course.id) requester = request.user - cc_requester = CommentClientUser.from_django_user(requester).retrieve() + cc_requester = CommentClientUser.from_django_user(requester).retrieve(course_id=course.id) cc_requester["course_id"] = course.id course_discussion_settings = CourseDiscussionSettings.get(course.id) is_global_staff = GlobalStaff().has_user(requester) diff --git a/lms/djangoapps/discussion/rest_api/tests/test_api.py b/lms/djangoapps/discussion/rest_api/tests/test_api.py index 9a9041fd5fa4..62725cc47466 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_api.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_api.py @@ -1248,6 +1248,22 @@ def setUp(self): httpretty.enable() self.addCleanup(httpretty.reset) self.addCleanup(httpretty.disable) + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment" + ) + self.mock_get_course_id_by_comment = patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) self.maxDiff = None # pylint: disable=invalid-name self.user = UserFactory.create() self.register_get_user_response(self.user) @@ -1872,6 +1888,12 @@ def setUp(self): httpretty.enable() self.addCleanup(httpretty.reset) self.addCleanup(httpretty.disable) + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) self.user = UserFactory.create() self.register_get_user_response(self.user) self.request = RequestFactory().get("/test_path") @@ -2198,6 +2220,22 @@ def setUp(self): self.course = CourseFactory.create() self.addCleanup(httpretty.reset) self.addCleanup(httpretty.disable) + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment" + ) + self.mock_get_course_id_by_comment = patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) self.user = UserFactory.create() self.register_get_user_response(self.user) self.request = RequestFactory().get("/test_path") @@ -2589,6 +2627,17 @@ def setUp(self): httpretty.enable() self.addCleanup(httpretty.reset) self.addCleanup(httpretty.disable) + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) self.user = UserFactory.create() self.register_get_user_response(self.user) @@ -3153,6 +3202,22 @@ def setUp(self): self.addCleanup(httpretty.reset) self.addCleanup(httpretty.disable) + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment" + ) + self.mock_get_course_id_by_comment = patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) self.user = UserFactory.create() self.register_get_user_response(self.user) self.request = RequestFactory().get("/test_path") @@ -3670,6 +3735,22 @@ def setUp(self): httpretty.enable() self.addCleanup(httpretty.reset) self.addCleanup(httpretty.disable) + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment" + ) + self.mock_get_course_id_by_comment = patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) self.user = UserFactory.create() self.register_get_user_response(self.user) self.request = RequestFactory().get("/test_path") @@ -3823,6 +3904,22 @@ def setUp(self): httpretty.enable() self.addCleanup(httpretty.reset) self.addCleanup(httpretty.disable) + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment" + ) + self.mock_get_course_id_by_comment = patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) self.user = UserFactory.create() self.register_get_user_response(self.user) self.request = RequestFactory().get("/test_path") @@ -3991,6 +4088,17 @@ def setUp(self): httpretty.enable() self.addCleanup(httpretty.reset) self.addCleanup(httpretty.disable) + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) self.user = UserFactory.create() self.register_get_user_response(self.user) self.request = RequestFactory().get("/test_path") diff --git a/lms/djangoapps/discussion/rest_api/tests/test_serializers.py b/lms/djangoapps/discussion/rest_api/tests/test_serializers.py index 8103eb692791..73b195e02fa6 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_serializers.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_serializers.py @@ -54,6 +54,12 @@ def setUp(self): httpretty.enable() self.addCleanup(httpretty.reset) self.addCleanup(httpretty.disable) + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) self.maxDiff = None # pylint: disable=invalid-name self.user = UserFactory.create() self.register_get_user_response(self.user) @@ -571,6 +577,12 @@ def setUp(self): httpretty.enable() self.addCleanup(httpretty.reset) self.addCleanup(httpretty.disable) + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) self.user = UserFactory.create() self.register_get_user_response(self.user) self.request = RequestFactory().get("/dummy") @@ -802,6 +814,22 @@ def setUp(self): httpretty.enable() self.addCleanup(httpretty.reset) self.addCleanup(httpretty.disable) + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment" + ) + self.mock_get_course_id_by_comment = patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) self.user = UserFactory.create() self.register_get_user_response(self.user) self.request = RequestFactory().get("/dummy") diff --git a/lms/djangoapps/discussion/rest_api/tests/test_tasks.py b/lms/djangoapps/discussion/rest_api/tests/test_tasks.py index 3a9eac32458d..fad0ad856c4c 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_tasks.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_tasks.py @@ -58,10 +58,27 @@ def setUp(self): Setup test case """ super().setUp() - + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) # Creating a course self.course = CourseFactory.create() + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread", + return_value=self.course.id + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment", + return_value=self.course.id + ) + self.mock_get_course_id_by_comment = patcher.start() + self.addCleanup(patcher.stop) # Creating relative discussion and cohort settings CourseCohortsSettings.objects.create(course_id=str(self.course.id)) CourseDiscussionSettings.objects.create(course_id=str(self.course.id), _divided_discussions='[]') @@ -250,8 +267,26 @@ def setUp(self): super().setUp() httpretty.reset() httpretty.enable() + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) self.course = CourseFactory.create() + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread", + return_value=self.course.id + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment", + return_value=self.course.id + ) + self.mock_get_course_id_by_comment = patcher.start() + self.addCleanup(patcher.stop) self.user_1 = UserFactory.create() CourseEnrollment.enroll(self.user_1, self.course.id) self.user_2 = UserFactory.create() @@ -538,8 +573,26 @@ def setUp(self): super().setUp() httpretty.reset() httpretty.enable() + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) self.course = CourseFactory.create() + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread", + return_value=self.course.id + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment", + return_value=self.course.id + ) + self.mock_get_course_id_by_comment = patcher.start() + self.addCleanup(patcher.stop) self.user_1 = UserFactory.create() CourseEnrollment.enroll(self.user_1, self.course.id) self.user_2 = UserFactory.create() @@ -605,8 +658,26 @@ def setUp(self): super().setUp() httpretty.reset() httpretty.enable() + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) self.course = CourseFactory.create() + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread", + return_value=self.course.id + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment", + return_value=self.course.id + ) + self.mock_get_course_id_by_comment = patcher.start() + self.addCleanup(patcher.stop) self.user_1 = UserFactory.create() CourseEnrollment.enroll(self.user_1, self.course.id) self.user_2 = UserFactory.create() diff --git a/lms/djangoapps/discussion/rest_api/tests/test_views.py b/lms/djangoapps/discussion/rest_api/tests/test_views.py index 283117000712..9ae03986bb93 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_views.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_views.py @@ -171,6 +171,12 @@ def setUp(self): self.user = UserFactory.create(password=self.TEST_PASSWORD) self.course = CourseFactory.create(org='a', course='b', run='c', start=datetime.now(UTC)) self.url = reverse("upload_file", kwargs={"course_id": str(self.course.id)}) + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) def user_login(self): """ @@ -301,6 +307,7 @@ def test_file_upload_with_no_data(self): @ddt.ddt @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) +@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_FORUM_V2": False}) class CommentViewSetListByUserTest( ForumsEnableMixin, CommentsServiceMockMixin, @@ -319,6 +326,12 @@ def setUp(self): httpretty.enable() self.addCleanup(httpretty.reset) self.addCleanup(httpretty.disable) + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) self.user = UserFactory.create(password=self.TEST_PASSWORD) self.register_get_user_response(self.user) @@ -500,6 +513,12 @@ class CourseViewTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase): def setUp(self): super().setUp() self.url = reverse("discussion_course", kwargs={"course_id": str(self.course.id)}) + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) def test_404(self): response = self.client.get( @@ -561,6 +580,12 @@ def setUp(self): self.superuser_client = APIClient() self.retired_username = get_retired_username_by_username(self.user.username) self.url = reverse("retire_discussion_user") + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) def assert_response_correct(self, response, expected_status, expected_content): """ @@ -631,6 +656,12 @@ def setUp(self): self.worker_client = APIClient() self.new_username = "test_username_replacement" self.url = reverse("replace_discussion_username") + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) def assert_response_correct(self, response, expected_status, expected_content): """ @@ -733,6 +764,12 @@ def setUp(self): "courseware-3": {"discussion": 7, "question": 2}, } self.register_get_course_commentable_counts_response(self.course.id, self.thread_counts_map) + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) def create_course(self, blocks_count, module_store, topics): """ @@ -988,6 +1025,12 @@ def setUp(self) -> None: patcher.start() self.addCleanup(patcher.stop) self.url = reverse("course_topics_v3", kwargs={"course_id": str(self.course.id)}) + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) def test_basic(self): response = self.client.get(self.url) @@ -1024,6 +1067,12 @@ def setUp(self): super().setUp() self.author = UserFactory.create() self.url = reverse("thread-list") + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) def create_source_thread(self, overrides=None): """ @@ -1365,6 +1414,12 @@ class ThreadViewSetCreateTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase): def setUp(self): super().setUp() self.url = reverse("thread-list") + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) def test_basic(self): self.register_get_user_response(self.user) @@ -1437,6 +1492,17 @@ def setUp(self): self.unsupported_media_type = JSONParser.media_type super().setUp() self.url = reverse("thread-detail", kwargs={"thread_id": "test_thread"}) + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) def test_basic(self): self.register_get_user_response(self.user) @@ -1581,6 +1647,17 @@ def setUp(self): super().setUp() self.url = reverse("thread-detail", kwargs={"thread_id": "test_thread"}) self.thread_id = "test_thread" + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) def test_basic(self): self.register_get_user_response(self.user) @@ -1681,6 +1758,12 @@ def setUp(self): ] self.url = reverse("discussion_learner_threads", kwargs={'course_id': str(self.course.id)}) + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) def update_thread(self, thread): """ @@ -1923,6 +2006,17 @@ def setUp(self): self.url = reverse("comment-list") self.thread_id = "test_thread" self.storage = get_profile_image_storage() + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) def create_source_comment(self, overrides=None): """ @@ -2377,6 +2471,22 @@ def setUp(self): super().setUp() self.url = reverse("comment-detail", kwargs={"comment_id": "test_comment"}) self.comment_id = "test_comment" + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment" + ) + self.mock_get_course_id_by_comment = patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) def test_basic(self): self.register_get_user_response(self.user) @@ -2416,6 +2526,23 @@ class CommentViewSetCreateTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase): def setUp(self): super().setUp() self.url = reverse("comment-list") + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) + + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment" + ) + self.mock_get_course_id_by_comment = patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) def test_basic(self): self.register_get_user_response(self.user) @@ -2518,6 +2645,22 @@ def setUp(self): httpretty.enable() self.addCleanup(httpretty.reset) self.addCleanup(httpretty.disable) + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment" + ) + self.mock_get_course_id_by_comment = patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) self.register_get_user_response(self.user) self.url = reverse("comment-detail", kwargs={"comment_id": "test_comment"}) @@ -2640,6 +2783,22 @@ def setUp(self): super().setUp() self.url = reverse("thread-detail", kwargs={"thread_id": "test_thread"}) self.thread_id = "test_thread" + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment" + ) + self.mock_get_course_id_by_comment = patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) def test_basic(self): self.register_get_user_response(self.user) @@ -2693,6 +2852,22 @@ def setUp(self): self.url = reverse("comment-detail", kwargs={"comment_id": "test_comment"}) self.thread_id = "test_thread" self.comment_id = "test_comment" + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment" + ) + self.mock_get_course_id_by_comment = patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) def make_comment_data(self, comment_id, parent_id=None, children=[]): # pylint: disable=W0102 """ @@ -2838,6 +3013,12 @@ def setUp(self): self.path = reverse('discussion_course_settings', kwargs={'course_id': str(self.course.id)}) self.password = self.TEST_PASSWORD self.user = UserFactory(username='staff', password=self.password, is_staff=True) + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) def _get_oauth_headers(self, user): """Return the OAuth headers for testing OAuth authentication""" @@ -3127,6 +3308,12 @@ class CourseDiscussionRolesAPIViewTest(APITestCase, UrlResetMixin, ModuleStoreTe @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) def setUp(self): super().setUp() + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) self.course = CourseFactory.create( org="x", course="y", @@ -3318,6 +3505,12 @@ class CourseActivityStatsTest(ForumsEnableMixin, UrlResetMixin, CommentsServiceM @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) def setUp(self) -> None: super().setUp() + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) self.course = CourseFactory.create() self.course_key = str(self.course.id) seed_permissions_roles(self.course.id) diff --git a/lms/djangoapps/discussion/tests/test_tasks.py b/lms/djangoapps/discussion/tests/test_tasks.py index 92dadac9d9ee..952a6c567a52 100644 --- a/lms/djangoapps/discussion/tests/test_tasks.py +++ b/lms/djangoapps/discussion/tests/test_tasks.py @@ -232,6 +232,22 @@ def setUp(self): thread_permalink = '/courses/discussion/dummy_discussion_id' self.permalink_patcher = mock.patch('lms.djangoapps.discussion.tasks.permalink', return_value=thread_permalink) self.mock_permalink = self.permalink_patcher.start() + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment" + ) + self.mock_get_course_id_by_comment = patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) def tearDown(self): super().tearDown() diff --git a/lms/djangoapps/discussion/tests/test_views.py b/lms/djangoapps/discussion/tests/test_views.py index e0d3b869da3d..facdb368f14f 100644 --- a/lms/djangoapps/discussion/tests/test_views.py +++ b/lms/djangoapps/discussion/tests/test_views.py @@ -4,6 +4,7 @@ import json import logging from datetime import datetime +from unittest import mock from unittest.mock import ANY, Mock, call, patch import ddt @@ -109,9 +110,20 @@ def setUp(self): config = ForumsConfig.current() config.enabled = True config.save() + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) @patch('common.djangoapps.student.models.user.cc.User.from_django_user') - @patch('common.djangoapps.student.models.user.cc.User.active_threads') + @patch('openedx.core.djangoapps.django_comment_common.comment_client.user.User.active_threads') def test_user_profile_exception(self, mock_threads, mock_from_django_user): # Mock the code that makes the HTTP requests to the cs_comment_service app @@ -323,6 +335,17 @@ class SingleThreadTestCase(ForumsEnableMixin, ModuleStoreTestCase): # lint-amne def setUp(self): super().setUp() + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) self.course = CourseFactory.create(discussion_topics={'dummy discussion': {'id': 'dummy_discussion_id'}}) self.student = UserFactory.create() @@ -513,6 +536,20 @@ class SingleThreadQueryCountTestCase(ForumsEnableMixin, ModuleStoreTestCase): Ensures the number of modulestore queries and number of sql queries are independent of the number of responses retrieved for a given discussion thread. """ + def setUp(self): + super().setUp() + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) + @ddt.data( # split mongo: 3 queries, regardless of thread response size. (False, 1, 2, 2, 21, 8), @@ -582,6 +619,20 @@ def call_single_thread(): @patch('requests.request', autospec=True) class SingleCohortedThreadTestCase(CohortedTestCase): # lint-amnesty, pylint: disable=missing-class-docstring + def setUp(self): + super().setUp() + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) + def _create_mock_cohorted_thread(self, mock_request): # lint-amnesty, pylint: disable=missing-function-docstring mock_text = "dummy content" mock_thread_id = "test_thread_id" @@ -644,6 +695,20 @@ def test_html(self, mock_request): @patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) class SingleThreadAccessTestCase(CohortedTestCase): # lint-amnesty, pylint: disable=missing-class-docstring + def setUp(self): + super().setUp() + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) + def call_view(self, mock_request, commentable_id, user, group_id, thread_group_id=None, pass_group_id=True): # lint-amnesty, pylint: disable=missing-function-docstring thread_id = "test_thread_id" mock_request.side_effect = make_mock_request_impl( @@ -746,6 +811,20 @@ def test_private_team_thread(self, mock_request): class SingleThreadGroupIdTestCase(CohortedTestCase, GroupIdAssertionMixin): # lint-amnesty, pylint: disable=missing-class-docstring cs_endpoint = "/threads/dummy_thread_id" + def setUp(self): + super().setUp() + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) + def call_view(self, mock_request, commentable_id, user, group_id, pass_group_id=True, is_ajax=False): # lint-amnesty, pylint: disable=missing-function-docstring mock_request.side_effect = make_mock_request_impl( course=self.course, text="dummy context", group_id=self.student_cohort.id @@ -881,6 +960,22 @@ class SingleThreadContentGroupTestCase(ForumsEnableMixin, UrlResetMixin, Content @patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) def setUp(self): super().setUp() + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment" + ) + self.mock_get_course_id_by_comment = patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) def assert_can_access(self, user, discussion_id, thread_id, should_have_access): """ @@ -1046,6 +1141,7 @@ def test_private_team_discussion(self, mock_request): @patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) +@patch('openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', autospec=True) class InlineDiscussionGroupIdTestCase( # lint-amnesty, pylint: disable=missing-class-docstring CohortedTestCase, CohortedTopicGroupIdTestMixin, @@ -1056,8 +1152,22 @@ class InlineDiscussionGroupIdTestCase( # lint-amnesty, pylint: disable=missing- def setUp(self): super().setUp() self.cohorted_commentable_id = 'cohorted_topic' - - def call_view(self, mock_request, commentable_id, user, group_id, pass_group_id=True): + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) + + def call_view( + self, + mock_is_forum_v2_enabled, + mock_request, + commentable_id, + user, + group_id, + pass_group_id=True + ): # pylint: disable=arguments-differ + mock_is_forum_v2_enabled.return_value = False kwargs = {'commentable_id': self.cohorted_commentable_id} if group_id: # avoid causing a server error when the LMS chokes attempting @@ -1084,8 +1194,9 @@ def call_view(self, mock_request, commentable_id, user, group_id, pass_group_id= commentable_id ) - def test_group_info_in_ajax_response(self, mock_request): + def test_group_info_in_ajax_response(self, mock_is_forum_v2_enabled, mock_request): response = self.call_view( + mock_is_forum_v2_enabled, mock_request, self.cohorted_commentable_id, self.student, @@ -1097,10 +1208,29 @@ def test_group_info_in_ajax_response(self, mock_request): @patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) +@patch('openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', autospec=True) class ForumFormDiscussionGroupIdTestCase(CohortedTestCase, CohortedTopicGroupIdTestMixin): # lint-amnesty, pylint: disable=missing-class-docstring cs_endpoint = "/threads" - def call_view(self, mock_request, commentable_id, user, group_id, pass_group_id=True, is_ajax=False): # pylint: disable=arguments-differ + def setUp(self): + super().setUp() + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) + + def call_view( + self, + mock_is_forum_v2_enabled, + mock_request, + commentable_id, + user, + group_id, + pass_group_id=True, + is_ajax=False + ): # pylint: disable=arguments-differ + mock_is_forum_v2_enabled.return_value = False kwargs = {} if group_id: kwargs['group_id'] = group_id @@ -1120,8 +1250,9 @@ def call_view(self, mock_request, commentable_id, user, group_id, pass_group_id= **headers ) - def test_group_info_in_html_response(self, mock_request): + def test_group_info_in_html_response(self, mock_is_forum_v2_enabled, mock_request): response = self.call_view( + mock_is_forum_v2_enabled, mock_request, "cohorted_topic", self.student, @@ -1129,8 +1260,9 @@ def test_group_info_in_html_response(self, mock_request): ) self._assert_html_response_contains_group_info(response) - def test_group_info_in_ajax_response(self, mock_request): + def test_group_info_in_ajax_response(self, mock_is_forum_v2_enabled, mock_request): response = self.call_view( + mock_is_forum_v2_enabled, mock_request, "cohorted_topic", self.student, @@ -1143,16 +1275,38 @@ def test_group_info_in_ajax_response(self, mock_request): @patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) +@patch('openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', autospec=True) class UserProfileDiscussionGroupIdTestCase(CohortedTestCase, CohortedTopicGroupIdTestMixin): # lint-amnesty, pylint: disable=missing-class-docstring cs_endpoint = "/active_threads" + def setUp(self): + super().setUp() + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment" + ) + self.mock_get_course_id_by_comment = patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) + def call_view_for_profiled_user( - self, mock_request, requesting_user, profiled_user, group_id, pass_group_id, is_ajax=False + self, + mock_is_forum_v2_enabled, + mock_request, + requesting_user, + profiled_user, + group_id, + pass_group_id, + is_ajax=False ): """ Calls "user_profile" view method on behalf of "requesting_user" to get information about the user "profiled_user". """ + mock_is_forum_v2_enabled.return_value = False kwargs = {} if group_id: kwargs['group_id'] = group_id @@ -1172,13 +1326,23 @@ def call_view_for_profiled_user( **headers ) - def call_view(self, mock_request, _commentable_id, user, group_id, pass_group_id=True, is_ajax=False): # pylint: disable=arguments-differ + def call_view( + self, + mock_is_forum_v2_enabled, + mock_request, + _commentable_id, + user, + group_id, + pass_group_id=True, + is_ajax=False + ): # pylint: disable=arguments-differ return self.call_view_for_profiled_user( - mock_request, user, user, group_id, pass_group_id=pass_group_id, is_ajax=is_ajax + mock_is_forum_v2_enabled, mock_request, user, user, group_id, pass_group_id=pass_group_id, is_ajax=is_ajax ) - def test_group_info_in_html_response(self, mock_request): + def test_group_info_in_html_response(self, mock_is_forum_v2_enabled, mock_request): response = self.call_view( + mock_is_forum_v2_enabled, mock_request, "cohorted_topic", self.student, @@ -1187,8 +1351,9 @@ def test_group_info_in_html_response(self, mock_request): ) self._assert_html_response_contains_group_info(response) - def test_group_info_in_ajax_response(self, mock_request): + def test_group_info_in_ajax_response(self, mock_is_forum_v2_enabled, mock_request): response = self.call_view( + mock_is_forum_v2_enabled, mock_request, "cohorted_topic", self.student, @@ -1200,7 +1365,14 @@ def test_group_info_in_ajax_response(self, mock_request): ) def _test_group_id_passed_to_user_profile( - self, mock_request, expect_group_id_in_request, requesting_user, profiled_user, group_id, pass_group_id + self, + mock_is_forum_v2_enabled, + mock_request, + expect_group_id_in_request, + requesting_user, + profiled_user, + group_id, + pass_group_id ): """ Helper method for testing whether or not group_id was passed to the user_profile request. @@ -1221,10 +1393,11 @@ def get_params_from_user_info_call(for_specific_course): has_course_id = "course_id" in params if (for_specific_course and has_course_id) or (not for_specific_course and not has_course_id): return params - pytest.fail("Did not find appropriate user_profile call for 'for_specific_course'=" + for_specific_course) + pytest.fail(f"Did not find appropriate user_profile call for 'for_specific_course'={for_specific_course}") mock_request.reset_mock() self.call_view_for_profiled_user( + mock_is_forum_v2_enabled, mock_request, requesting_user, profiled_user, @@ -1243,7 +1416,7 @@ def get_params_from_user_info_call(for_specific_course): else: assert 'group_id' not in params_with_course_id - def test_group_id_passed_to_user_profile_student(self, mock_request): + def test_group_id_passed_to_user_profile_student(self, mock_is_forum_v2_enabled, mock_request): """ Test that the group id is always included when requesting user profile information for a particular course if the requester does not have discussion moderation privileges. @@ -1254,7 +1427,13 @@ def verify_group_id_always_present(profiled_user, pass_group_id): (non-privileged user). """ self._test_group_id_passed_to_user_profile( - mock_request, True, self.student, profiled_user, self.student_cohort.id, pass_group_id + mock_is_forum_v2_enabled, + mock_request, + True, + self.student, + profiled_user, + self.student_cohort.id, + pass_group_id ) # In all these test cases, the requesting_user is the student (non-privileged user). @@ -1264,7 +1443,7 @@ def verify_group_id_always_present(profiled_user, pass_group_id): verify_group_id_always_present(profiled_user=self.moderator, pass_group_id=True) verify_group_id_always_present(profiled_user=self.moderator, pass_group_id=False) - def test_group_id_user_profile_moderator(self, mock_request): + def test_group_id_user_profile_moderator(self, mock_is_forum_v2_enabled, mock_request): """ Test that the group id is only included when a privileged user requests user profile information for a particular course and user if the group_id is explicitly passed in. @@ -1274,7 +1453,13 @@ def verify_group_id_present(profiled_user, pass_group_id, requested_cohort=self. Helper method to verify that group_id is present. """ self._test_group_id_passed_to_user_profile( - mock_request, True, self.moderator, profiled_user, requested_cohort.id, pass_group_id + mock_is_forum_v2_enabled, + mock_request, + True, + self.moderator, + profiled_user, + requested_cohort.id, + pass_group_id ) def verify_group_id_not_present(profiled_user, pass_group_id, requested_cohort=self.moderator_cohort): @@ -1282,7 +1467,13 @@ def verify_group_id_not_present(profiled_user, pass_group_id, requested_cohort=s Helper method to verify that group_id is not present. """ self._test_group_id_passed_to_user_profile( - mock_request, False, self.moderator, profiled_user, requested_cohort.id, pass_group_id + mock_is_forum_v2_enabled, + mock_request, + False, + self.moderator, + profiled_user, + requested_cohort.id, + pass_group_id ) # In all these test cases, the requesting_user is the moderator (privileged user). @@ -1301,10 +1492,28 @@ def verify_group_id_not_present(profiled_user, pass_group_id, requested_cohort=s @patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) +@patch('openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', autospec=True) class FollowedThreadsDiscussionGroupIdTestCase(CohortedTestCase, CohortedTopicGroupIdTestMixin): # lint-amnesty, pylint: disable=missing-class-docstring cs_endpoint = "/subscribed_threads" - def call_view(self, mock_request, commentable_id, user, group_id, pass_group_id=True): + def setUp(self): + super().setUp() + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) + + def call_view( + self, + mock_is_forum_v2_enabled, + mock_request, + commentable_id, + user, + group_id, + pass_group_id=True + ): # pylint: disable=arguments-differ + mock_is_forum_v2_enabled.return_value = False kwargs = {} if group_id: kwargs['group_id'] = group_id @@ -1325,8 +1534,9 @@ def call_view(self, mock_request, commentable_id, user, group_id, pass_group_id= user.id ) - def test_group_info_in_ajax_response(self, mock_request): + def test_group_info_in_ajax_response(self, mock_is_forum_v2_enabled, mock_request): response = self.call_view( + mock_is_forum_v2_enabled, mock_request, "cohorted_topic", self.student, @@ -1528,6 +1738,22 @@ class CommentsServiceRequestHeadersTestCase(ForumsEnableMixin, UrlResetMixin, Mo @patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) def setUp(self): super().setUp() + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment" + ) + self.mock_get_course_id_by_comment = patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) username = "foo" password = "bar" @@ -1742,6 +1968,20 @@ def setUpClass(cls): with super().setUpClassAndTestData(): cls.course = CourseFactory.create(discussion_topics={'dummy_discussion_id': {'id': 'dummy_discussion_id'}}) + def setUp(self): + super().setUp() + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) + @classmethod def setUpTestData(cls): super().setUpTestData() @@ -1858,7 +2098,17 @@ class EnterpriseConsentTestCase(EnterpriseTestConsentRequired, ForumsEnableMixin def setUp(self): # Invoke UrlResetMixin setUp super().setUp() - + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) username = "foo" password = "bar" @@ -2195,6 +2445,17 @@ class ThreadViewedEventTestCase(EventTestMixin, ForumsEnableMixin, UrlResetMixin def setUp(self): # pylint: disable=arguments-differ super().setUp('lms.djangoapps.discussion.django_comment_client.base.views.tracker') + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) self.course = CourseFactory.create( teams_configuration=TeamsConfig({ 'topics': [{ diff --git a/lms/djangoapps/discussion/toggles.py b/lms/djangoapps/discussion/toggles.py index a1c292a4734f..a01a3b6a0a59 100644 --- a/lms/djangoapps/discussion/toggles.py +++ b/lms/djangoapps/discussion/toggles.py @@ -1,6 +1,7 @@ """ Discussions feature toggles """ + from openedx.core.djangoapps.discussions.config.waffle import WAFFLE_FLAG_NAMESPACE from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag @@ -11,4 +12,6 @@ # .. toggle_use_cases: temporary, open_edx # .. toggle_creation_date: 2021-11-05 # .. toggle_target_removal_date: 2022-12-05 -ENABLE_DISCUSSIONS_MFE = CourseWaffleFlag(f'{WAFFLE_FLAG_NAMESPACE}.enable_discussions_mfe', __name__) +ENABLE_DISCUSSIONS_MFE = CourseWaffleFlag( + f"{WAFFLE_FLAG_NAMESPACE}.enable_discussions_mfe", __name__ +) diff --git a/lms/djangoapps/mobile_api/users/tests.py b/lms/djangoapps/mobile_api/users/tests.py index 6cd5e3d29a4e..2cfefaa058d9 100644 --- a/lms/djangoapps/mobile_api/users/tests.py +++ b/lms/djangoapps/mobile_api/users/tests.py @@ -9,6 +9,7 @@ import ddt import pytz +from completion.models import BlockCompletion from completion.test_utils import CompletionWaffleTestMixin, submit_completions_for_testing from django.conf import settings from django.db import transaction @@ -1381,3 +1382,145 @@ def test_discussion_tab_url(self, discussion_tab_enabled): assert isinstance(discussion_url, str) else: assert discussion_url is None + + +@ddt.ddt +class TestUserEnrollmentsStatus(MobileAPITestCase, MobileAuthUserTestMixin): + """ + Tests for /api/mobile/{api_version}/users//enrollments_status/ + """ + + REVERSE_INFO = {'name': 'user-enrollments-status', 'params': ['username', 'api_version']} + + def test_no_mobile_available_courses(self) -> None: + self.login() + courses = [CourseFactory.create(org="edx", mobile_available=False) for _ in range(3)] + for course in courses: + self.enroll(course.id) + + response = self.api_response(api_version=API_V1) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertListEqual(response.data, []) + + def test_no_enrollments(self) -> None: + self.login() + for _ in range(3): + CourseFactory.create(org="edx", mobile_available=True) + + response = self.api_response(api_version=API_V1) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertListEqual(response.data, []) + + def test_user_have_only_active_enrollments_and_no_completions(self) -> None: + self.login() + courses = [CourseFactory.create(org="edx", mobile_available=True) for _ in range(3)] + for course in courses: + self.enroll(course.id) + + response = self.api_response(api_version=API_V1) + + expected_response = [ + {'course_id': str(courses[0].course_id), 'course_name': courses[0].display_name, 'recently_active': True}, + {'course_id': str(courses[1].course_id), 'course_name': courses[1].display_name, 'recently_active': True}, + {'course_id': str(courses[2].course_id), 'course_name': courses[2].display_name, 'recently_active': True}, + ] + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertListEqual(response.data, expected_response) + + def test_user_have_active_and_inactive_enrollments_and_no_completions(self) -> None: + self.login() + courses = [CourseFactory.create(org="edx", mobile_available=True) for _ in range(3)] + for course in courses: + self.enroll(course.id) + old_course = CourseFactory.create(org="edx", mobile_available=True) + self.enroll(old_course.id) + old_enrollment = CourseEnrollment.objects.filter(user=self.user, course=old_course.course_id).first() + old_enrollment.created = datetime.datetime.now(pytz.UTC) - datetime.timedelta(days=31) + old_enrollment.save() + + response = self.api_response(api_version=API_V1) + + expected_response = [ + {'course_id': str(courses[0].course_id), 'course_name': courses[0].display_name, 'recently_active': True}, + {'course_id': str(courses[1].course_id), 'course_name': courses[1].display_name, 'recently_active': True}, + {'course_id': str(courses[2].course_id), 'course_name': courses[2].display_name, 'recently_active': True}, + {'course_id': str(old_course.course_id), 'course_name': old_course.display_name, 'recently_active': False} + ] + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertListEqual(response.data, expected_response) + + @ddt.data( + (27, True), + (28, True), + (29, True), + (31, False), + (32, False), + ) + @ddt.unpack + def test_different_enrollment_dates(self, enrolled_days_ago: int, recently_active_status: bool) -> None: + self.login() + course = CourseFactory.create(org="edx", mobile_available=True, run='1001') + self.enroll(course.id) + enrollment = CourseEnrollment.objects.filter(user=self.user, course=course.course_id).first() + enrollment.created = datetime.datetime.now(pytz.UTC) - datetime.timedelta(days=enrolled_days_ago) + enrollment.save() + + response = self.api_response(api_version=API_V1) + + expected_response = [ + { + 'course_id': str(course.course_id), + 'course_name': course.display_name, + 'recently_active': recently_active_status + } + ] + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertListEqual(response.data, expected_response) + + @ddt.data( + (27, True), + (28, True), + (29, True), + (31, False), + (32, False), + ) + @ddt.unpack + def test_different_completion_dates(self, completed_days_ago: int, recently_active_status: bool) -> None: + self.login() + course = CourseFactory.create(org="edx", mobile_available=True, run='1010') + section = BlockFactory.create( + parent=course, + category='chapter', + ) + self.enroll(course.id) + enrollment = CourseEnrollment.objects.filter(user=self.user, course=course.course_id).first() + # make enrollment older 30 days ago + enrollment.created = datetime.datetime.now(pytz.UTC) - datetime.timedelta(days=50) + enrollment.save() + completion = BlockCompletion.objects.create( + user=self.user, + context_key=course.context_key, + block_type='course', + block_key=section.location, + completion=0.5, + ) + completion.created = datetime.datetime.now(pytz.UTC) - datetime.timedelta(days=completed_days_ago) + completion.save() + + response = self.api_response(api_version=API_V1) + + expected_response = [ + { + 'course_id': str(course.course_id), + 'course_name': course.display_name, + 'recently_active': recently_active_status + } + ] + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertListEqual(response.data, expected_response) diff --git a/lms/djangoapps/mobile_api/users/urls.py b/lms/djangoapps/mobile_api/users/urls.py index 266644246e88..874730d4d0f0 100644 --- a/lms/djangoapps/mobile_api/users/urls.py +++ b/lms/djangoapps/mobile_api/users/urls.py @@ -6,7 +6,7 @@ from django.conf import settings from django.urls import re_path -from .views import UserCourseEnrollmentsList, UserCourseStatus, UserDetail +from .views import UserCourseEnrollmentsList, UserCourseStatus, UserDetail, UserEnrollmentsStatus urlpatterns = [ re_path('^' + settings.USERNAME_PATTERN + '$', UserDetail.as_view(), name='user-detail'), @@ -17,5 +17,8 @@ ), re_path(f'^{settings.USERNAME_PATTERN}/course_status_info/{settings.COURSE_ID_PATTERN}', UserCourseStatus.as_view(), - name='user-course-status') + name='user-course-status'), + re_path(f'^{settings.USERNAME_PATTERN}/enrollments_status/', + UserEnrollmentsStatus.as_view(), + name='user-enrollments-status') ] diff --git a/lms/djangoapps/mobile_api/users/views.py b/lms/djangoapps/mobile_api/users/views.py index d959e188b4ee..c86f3add9d36 100644 --- a/lms/djangoapps/mobile_api/users/views.py +++ b/lms/djangoapps/mobile_api/users/views.py @@ -3,11 +3,14 @@ """ +import datetime import logging from functools import cached_property -from typing import Optional +from typing import Dict, List, Optional, Set +import pytz from completion.exceptions import UnavailableCompletionData +from completion.models import BlockCompletion from completion.utilities import get_key_to_last_completed_block from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user from django.contrib.auth.signals import user_logged_in @@ -17,6 +20,7 @@ from django.utils.decorators import method_decorator from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import UsageKey +from opaque_keys.edx.locator import CourseLocator from rest_framework import generics, views from rest_framework.decorators import api_view from rest_framework.permissions import SAFE_METHODS @@ -530,6 +534,133 @@ def my_user_info(request, api_version): return redirect("user-detail", api_version=api_version, username=request.user.username) +@mobile_view(is_user=True) +class UserEnrollmentsStatus(views.APIView): + """ + **Use Case** + + Get information about user's enrolments status. + + Returns active enrolment status if user was enrolled for the course + less than 30 days ago or has progressed in the course in the last 30 days. + Otherwise, the registration is considered inactive. + + USER_ENROLLMENTS_LIMIT - adds users enrollments query limit to + safe API from possible DDOS attacks. + + **Example Request** + + GET /api/mobile/{api_version}/users//enrollments_status/ + + **Response Values** + + If the request for information about the user's enrolments is successful, the + request returns an HTTP 200 "OK" response. + + The HTTP 200 response has the following values. + + * course_id (str): The course id associated with the user's enrollment. + * course_name (str): The course name associated with the user's enrollment. + * recently_active (bool): User's course enrolment status. + + + The HTTP 200 response contains a list of dictionaries that contain info + about each user's enrolment status. + + **Example Response** + + ```json + [ + { + "course_id": "course-v1:a+a+a", + "course_name": "a", + "recently_active": true + }, + { + "course_id": "course-v1:b+b+b", + "course_name": "b", + "recently_active": true + }, + { + "course_id": "course-v1:c+c+c", + "course_name": "c", + "recently_active": false + }, + ... + ] + ``` + """ + + USER_ENROLLMENTS_LIMIT = 500 + + def get(self, request, *args, **kwargs) -> Response: + """ + Gets user's enrollments status. + """ + active_status_date = datetime.datetime.now(pytz.UTC) - datetime.timedelta(days=30) + username = kwargs.get('username') + course_ids_where_user_has_completions = self._get_course_ids_where_user_has_completions( + username, + active_status_date, + ) + enrollments_status = self._build_enrollments_status_dict( + username, + active_status_date, + course_ids_where_user_has_completions + ) + return Response(enrollments_status) + + def _build_enrollments_status_dict( + self, + username: str, + active_status_date: datetime, + course_ids: Set[CourseLocator], + ) -> List[Dict[str, bool]]: + """ + Builds list with dictionaries with user's enrolments statuses. + """ + user = get_object_or_404(User, username=username) + user_enrollments = ( + CourseEnrollment + .enrollments_for_user(user) + .select_related('course') + [:self.USER_ENROLLMENTS_LIMIT] + ) + mobile_available = [ + enrollment for enrollment in user_enrollments + if is_mobile_available_for_user(user, enrollment.course_overview) + ] + enrollments_status = [] + for user_enrollment in mobile_available: + course_id = user_enrollment.course_overview.id + enrollments_status.append( + { + 'course_id': str(course_id), + 'course_name': user_enrollment.course_overview.display_name, + 'recently_active': bool( + course_id in course_ids + or user_enrollment.created > active_status_date + ) + } + ) + return enrollments_status + + @staticmethod + def _get_course_ids_where_user_has_completions( + username: str, + active_status_date: datetime, + ) -> Set[CourseLocator]: + """ + Gets course keys where user has completions. + """ + context_keys = BlockCompletion.objects.filter( + user__username=username, + created__gte=active_status_date + ).values_list('context_key', flat=True).distinct() + + return set(context_keys) + + class UserCourseEnrollmentsV4Pagination(DefaultPagination): """ Pagination for `UserCourseEnrollments` API v4. diff --git a/openedx/core/djangoapps/content/block_structure/manager.py b/openedx/core/djangoapps/content/block_structure/manager.py index 2f8a041c3163..49f423ce7ac3 100644 --- a/openedx/core/djangoapps/content/block_structure/manager.py +++ b/openedx/core/djangoapps/content/block_structure/manager.py @@ -35,7 +35,7 @@ def __init__(self, root_block_usage_key, modulestore, cache): self.modulestore = modulestore self.store = BlockStructureStore(cache) - def get_transformed(self, transformers, starting_block_usage_key=None, collected_block_structure=None): + def get_transformed(self, transformers, starting_block_usage_key=None, collected_block_structure=None, user=None): """ Returns the transformed Block Structure for the root_block_usage_key, starting at starting_block_usage_key, getting block data from the cache @@ -57,11 +57,14 @@ def get_transformed(self, transformers, starting_block_usage_key=None, collected get_collected. Can be optionally provided if already available, for optimization. + user (django.contrib.auth.models.User) - User object for + which the block structure is to be transformed. + Returns: BlockStructureBlockData - A transformed block structure, starting at starting_block_usage_key. """ - block_structure = collected_block_structure.copy() if collected_block_structure else self.get_collected() + block_structure = collected_block_structure.copy() if collected_block_structure else self.get_collected(user) if starting_block_usage_key: # Override the root_block_usage_key so traversals start at the @@ -77,7 +80,7 @@ def get_transformed(self, transformers, starting_block_usage_key=None, collected transformers.transform(block_structure) return block_structure - def get_collected(self): + def get_collected(self, user=None): """ Returns the collected Block Structure for the root_block_usage_key, getting block data from the cache and modulestore, as needed. @@ -86,6 +89,10 @@ def get_collected(self): the modulestore is accessed if needed (at cache miss), and the transformers data is collected if needed. + In the case of a cache miss, the function bypasses runtime access checks for the current + user. This is done to prevent inconsistencies in the data, which can occur when + certain blocks are inaccessible due to access restrictions. + Returns: BlockStructureBlockData - A collected block structure, starting at root_block_usage_key, with collected data @@ -99,7 +106,15 @@ def get_collected(self): BlockStructureTransformers.verify_versions(block_structure) except (BlockStructureNotFound, TransformerDataIncompatible): - block_structure = self._update_collected() + if user and getattr(user, "known", True): + # This bypasses the runtime access checks. When we are populating the course blocks cache, + # we do not want to perform access checks. Access checks result in inconsistent blocks where + # inaccessible blocks are missing from the cache. Cached course blocks are then used for all the users. + user.known = False + block_structure = self._update_collected() + user.known = True + else: + block_structure = self._update_collected() return block_structure diff --git a/openedx/core/djangoapps/content/search/api.py b/openedx/core/djangoapps/content/search/api.py index b1d224b411e4..a18c55bd3d22 100644 --- a/openedx/core/djangoapps/content/search/api.py +++ b/openedx/core/djangoapps/content/search/api.py @@ -5,7 +5,7 @@ import logging import time -from contextlib import contextmanager +from contextlib import contextmanager, nullcontext from datetime import datetime, timedelta, timezone from functools import wraps from typing import Callable, Generator @@ -24,7 +24,14 @@ from rest_framework.request import Request from common.djangoapps.student.role_helpers import get_course_roles from openedx.core.djangoapps.content.course_overviews.models import CourseOverview -from openedx.core.djangoapps.content.search.models import get_access_ids_for_request +from openedx.core.djangoapps.content.search.models import get_access_ids_for_request, IncrementalIndexCompleted +from openedx.core.djangoapps.content.search.index_config import ( + INDEX_DISTINCT_ATTRIBUTE, + INDEX_FILTERABLE_ATTRIBUTES, + INDEX_SEARCHABLE_ATTRIBUTES, + INDEX_SORTABLE_ATTRIBUTES, + INDEX_RANKING_RULES, +) from openedx.core.djangoapps.content_libraries import api as lib_api from xmodule.modulestore.django import modulestore @@ -217,6 +224,42 @@ def _using_temp_index(status_cb: Callable[[str], None] | None = None) -> Generat _wait_for_meili_task(client.delete_index(temp_index_name)) +def _index_is_empty(index_name: str) -> bool: + """ + Check if an index is empty + + Args: + index_name (str): The name of the index to check + """ + client = _get_meilisearch_client() + index = client.get_index(index_name) + return index.get_stats().number_of_documents == 0 + + +def _configure_index(index_name): + """ + Configure the index. The following index settings are best changed on an empty index. + Changing them on a populated index will "re-index all documents in the index", which can take some time. + + Args: + index_name (str): The name of the index to configure + """ + client = _get_meilisearch_client() + + # Mark usage_key as unique (it's not the primary key for the index, but nevertheless must be unique): + client.index(index_name).update_distinct_attribute(INDEX_DISTINCT_ATTRIBUTE) + # Mark which attributes can be used for filtering/faceted search: + client.index(index_name).update_filterable_attributes(INDEX_FILTERABLE_ATTRIBUTES) + # Mark which attributes are used for keyword search, in order of importance: + client.index(index_name).update_searchable_attributes(INDEX_SEARCHABLE_ATTRIBUTES) + # Mark which attributes can be used for sorting search results: + client.index(index_name).update_sortable_attributes(INDEX_SORTABLE_ATTRIBUTES) + + # Update the search ranking rules to let the (optional) "sort" parameter take precedence over keyword relevance. + # cf https://www.meilisearch.com/docs/learn/core_concepts/relevancy + client.index(index_name).update_ranking_rules(INDEX_RANKING_RULES) + + def _recurse_children(block, fn, status_cb: Callable[[str], None] | None = None) -> None: """ Recurse the children of an XBlock and call the given function for each @@ -279,8 +322,75 @@ def is_meilisearch_enabled() -> bool: return False -# pylint: disable=too-many-statements -def rebuild_index(status_cb: Callable[[str], None] | None = None) -> None: +def reset_index(status_cb: Callable[[str], None] | None = None) -> None: + """ + Reset the Meilisearch index, deleting all documents and reconfiguring it + """ + if status_cb is None: + status_cb = log.info + + status_cb("Creating new empty index...") + with _using_temp_index(status_cb) as temp_index_name: + _configure_index(temp_index_name) + status_cb("Index recreated!") + status_cb("Index reset complete.") + + +def _is_index_configured(index_name: str) -> bool: + """ + Check if an index is completely configured + + Args: + index_name (str): The name of the index to check + """ + client = _get_meilisearch_client() + index = client.get_index(index_name) + index_settings = index.get_settings() + for k, v in ( + ("distinctAttribute", INDEX_DISTINCT_ATTRIBUTE), + ("filterableAttributes", INDEX_FILTERABLE_ATTRIBUTES), + ("searchableAttributes", INDEX_SEARCHABLE_ATTRIBUTES), + ("sortableAttributes", INDEX_SORTABLE_ATTRIBUTES), + ("rankingRules", INDEX_RANKING_RULES), + ): + setting = index_settings.get(k, []) + if isinstance(v, list): + v = set(v) + setting = set(setting) + if setting != v: + return False + return True + + +def init_index(status_cb: Callable[[str], None] | None = None, warn_cb: Callable[[str], None] | None = None) -> None: + """ + Initialize the Meilisearch index, creating it and configuring it if it doesn't exist + """ + if status_cb is None: + status_cb = log.info + if warn_cb is None: + warn_cb = log.warning + + if _index_exists(STUDIO_INDEX_NAME): + if _index_is_empty(STUDIO_INDEX_NAME): + warn_cb( + "The studio search index is empty. Please run ./manage.py cms reindex_studio" + " --experimental [--incremental]" + ) + return + if not _is_index_configured(STUDIO_INDEX_NAME): + warn_cb( + "A rebuild of the index is required. Please run ./manage.py cms reindex_studio" + " --experimental [--incremental]" + ) + return + status_cb("Index already exists and is configured.") + return + + reset_index(status_cb) + + +def rebuild_index(status_cb: Callable[[str], None] | None = None, incremental=False) -> None: # lint-amnesty, pylint: disable=too-many-statements """ Rebuild the Meilisearch index from scratch """ @@ -292,7 +402,14 @@ def rebuild_index(status_cb: Callable[[str], None] | None = None) -> None: # Get the lists of libraries status_cb("Counting libraries...") - lib_keys = [lib.library_key for lib in lib_api.ContentLibrary.objects.select_related('org').only('org', 'slug')] + keys_indexed = [] + if incremental: + keys_indexed = list(IncrementalIndexCompleted.objects.values_list("context_key", flat=True)) + lib_keys = [ + lib.library_key + for lib in lib_api.ContentLibrary.objects.select_related("org").only("org", "slug").order_by("-id") + if lib.library_key not in keys_indexed + ] num_libraries = len(lib_keys) # Get the list of courses @@ -300,88 +417,25 @@ def rebuild_index(status_cb: Callable[[str], None] | None = None) -> None: num_courses = CourseOverview.objects.count() # Some counters so we can track our progress as indexing progresses: - num_contexts = num_courses + num_libraries - num_contexts_done = 0 # How many courses/libraries we've indexed + num_libs_skipped = len(keys_indexed) + num_contexts = num_courses + num_libraries + num_libs_skipped + num_contexts_done = 0 + num_libs_skipped # How many courses/libraries we've indexed num_blocks_done = 0 # How many individual components/XBlocks we've indexed status_cb(f"Found {num_courses} courses, {num_libraries} libraries.") - with _using_temp_index(status_cb) as temp_index_name: + with _using_temp_index(status_cb) if not incremental else nullcontext(STUDIO_INDEX_NAME) as index_name: ############## Configure the index ############## - # The following index settings are best changed on an empty index. - # Changing them on a populated index will "re-index all documents in the index, which can take some time" + # The index settings are best changed on an empty index. + # Changing them on a populated index will "re-index all documents in the index", which can take some time # and use more RAM. Instead, we configure an empty index then populate it one course/library at a time. - - # Mark usage_key as unique (it's not the primary key for the index, but nevertheless must be unique): - client.index(temp_index_name).update_distinct_attribute(Fields.usage_key) - # Mark which attributes can be used for filtering/faceted search: - client.index(temp_index_name).update_filterable_attributes([ - # Get specific block/collection using combination of block_id and context_key - Fields.block_id, - Fields.block_type, - Fields.context_key, - Fields.usage_key, - Fields.org, - Fields.tags, - Fields.tags + "." + Fields.tags_taxonomy, - Fields.tags + "." + Fields.tags_level0, - Fields.tags + "." + Fields.tags_level1, - Fields.tags + "." + Fields.tags_level2, - Fields.tags + "." + Fields.tags_level3, - Fields.collections, - Fields.collections + "." + Fields.collections_display_name, - Fields.collections + "." + Fields.collections_key, - Fields.type, - Fields.access_id, - Fields.last_published, - Fields.content + "." + Fields.problem_types, - ]) - # Mark which attributes are used for keyword search, in order of importance: - client.index(temp_index_name).update_searchable_attributes([ - # Keyword search does _not_ search the course name, course ID, breadcrumbs, block type, or other fields. - Fields.display_name, - Fields.block_id, - Fields.content, - Fields.description, - Fields.tags, - Fields.collections, - # If we don't list the following sub-fields _explicitly_, they're only sometimes searchable - that is, they - # are searchable only if at least one document in the index has a value. If we didn't list them here and, - # say, there were no tags.level3 tags in the index, the client would get an error if trying to search for - # these sub-fields: "Attribute `tags.level3` is not searchable." - Fields.tags + "." + Fields.tags_taxonomy, - Fields.tags + "." + Fields.tags_level0, - Fields.tags + "." + Fields.tags_level1, - Fields.tags + "." + Fields.tags_level2, - Fields.tags + "." + Fields.tags_level3, - Fields.collections + "." + Fields.collections_display_name, - Fields.collections + "." + Fields.collections_key, - Fields.published + "." + Fields.display_name, - Fields.published + "." + Fields.published_description, - ]) - # Mark which attributes can be used for sorting search results: - client.index(temp_index_name).update_sortable_attributes([ - Fields.display_name, - Fields.created, - Fields.modified, - Fields.last_published, - ]) - - # Update the search ranking rules to let the (optional) "sort" parameter take precedence over keyword relevance. - # cf https://www.meilisearch.com/docs/learn/core_concepts/relevancy - client.index(temp_index_name).update_ranking_rules([ - "sort", - "words", - "typo", - "proximity", - "attribute", - "exactness", - ]) + if not incremental: + _configure_index(index_name) ############## Libraries ############## status_cb("Indexing libraries...") - def index_library(lib_key: str) -> list: + def index_library(lib_key: LibraryLocatorV2) -> list: docs = [] for component in lib_api.get_library_components(lib_key): try: @@ -396,7 +450,7 @@ def index_library(lib_key: str) -> list: if docs: try: # Add all the docs in this library at once (usually faster than adding one at a time): - _wait_for_meili_task(client.index(temp_index_name).add_documents(docs)) + _wait_for_meili_task(client.index(index_name).add_documents(docs)) except (TypeError, KeyError, MeilisearchError) as err: status_cb(f"Error indexing library {lib_key}: {err}") return docs @@ -416,7 +470,7 @@ def index_collection_batch(batch, num_done, library_key) -> int: if docs: try: # Add docs in batch of 100 at once (usually faster than adding one at a time): - _wait_for_meili_task(client.index(temp_index_name).add_documents(docs)) + _wait_for_meili_task(client.index(index_name).add_documents(docs)) except (TypeError, KeyError, MeilisearchError) as err: status_cb(f"Error indexing collection batch {p}: {err}") return num_done @@ -439,6 +493,8 @@ def index_collection_batch(batch, num_done, library_key) -> int: num_collections_done, lib_key, ) + if incremental: + IncrementalIndexCompleted.objects.get_or_create(context_key=lib_key) status_cb(f"{num_collections_done}/{num_collections} collections indexed for library {lib_key}") num_contexts_done += 1 @@ -464,7 +520,7 @@ def add_with_children(block): if docs: # Add all the docs in this course at once (usually faster than adding one at a time): - _wait_for_meili_task(client.index(temp_index_name).add_documents(docs)) + _wait_for_meili_task(client.index(index_name).add_documents(docs)) return docs paginator = Paginator(CourseOverview.objects.only('id', 'display_name'), 1000) @@ -473,10 +529,16 @@ def add_with_children(block): status_cb( f"{num_contexts_done + 1}/{num_contexts}. Now indexing course {course.display_name} ({course.id})" ) + if course.id in keys_indexed: + num_contexts_done += 1 + continue course_docs = index_course(course) + if incremental: + IncrementalIndexCompleted.objects.get_or_create(context_key=course.id) num_contexts_done += 1 num_blocks_done += len(course_docs) + IncrementalIndexCompleted.objects.all().delete() status_cb(f"Done! {num_blocks_done} blocks indexed across {num_contexts_done} courses, collections and libraries.") diff --git a/openedx/core/djangoapps/content/search/documents.py b/openedx/core/djangoapps/content/search/documents.py index fb385ccc4503..5d11c1c50da6 100644 --- a/openedx/core/djangoapps/content/search/documents.py +++ b/openedx/core/djangoapps/content/search/documents.py @@ -80,6 +80,7 @@ class Fields: published = "published" published_display_name = "display_name" published_description = "description" + published_num_children = "num_children" # Note: new fields or values can be added at any time, but if they need to be indexed for filtering or keyword # search, the index configuration will need to be changed, which is only done as part of the 'reindex_studio' @@ -485,6 +486,15 @@ def searchable_doc_for_collection( if collection: assert collection.key == collection_key + draft_num_children = authoring_api.filter_publishable_entities( + collection.entities, + has_draft=True, + ).count() + published_num_children = authoring_api.filter_publishable_entities( + collection.entities, + has_published=True, + ).count() + doc.update({ Fields.context_key: str(library_key), Fields.org: str(library_key.org), @@ -495,7 +505,10 @@ def searchable_doc_for_collection( Fields.description: collection.description, Fields.created: collection.created.timestamp(), Fields.modified: collection.modified.timestamp(), - Fields.num_children: collection.entities.count(), + Fields.num_children: draft_num_children, + Fields.published: { + Fields.published_num_children: published_num_children, + }, Fields.access_id: _meili_access_id_from_context_key(library_key), Fields.breadcrumbs: [{"display_name": collection.learning_package.title}], }) diff --git a/openedx/core/djangoapps/content/search/index_config.py b/openedx/core/djangoapps/content/search/index_config.py new file mode 100644 index 000000000000..9570956e425e --- /dev/null +++ b/openedx/core/djangoapps/content/search/index_config.py @@ -0,0 +1,70 @@ +"""Configuration for the search index.""" +from .documents import Fields + + +INDEX_DISTINCT_ATTRIBUTE = "usage_key" + +# Mark which attributes can be used for filtering/faceted search: +INDEX_FILTERABLE_ATTRIBUTES = [ + # Get specific block/collection using combination of block_id and context_key + Fields.block_id, + Fields.block_type, + Fields.context_key, + Fields.usage_key, + Fields.org, + Fields.tags, + Fields.tags + "." + Fields.tags_taxonomy, + Fields.tags + "." + Fields.tags_level0, + Fields.tags + "." + Fields.tags_level1, + Fields.tags + "." + Fields.tags_level2, + Fields.tags + "." + Fields.tags_level3, + Fields.collections, + Fields.collections + "." + Fields.collections_display_name, + Fields.collections + "." + Fields.collections_key, + Fields.type, + Fields.access_id, + Fields.last_published, + Fields.content + "." + Fields.problem_types, +] + +# Mark which attributes are used for keyword search, in order of importance: +INDEX_SEARCHABLE_ATTRIBUTES = [ + # Keyword search does _not_ search the course name, course ID, breadcrumbs, block type, or other fields. + Fields.display_name, + Fields.block_id, + Fields.content, + Fields.description, + Fields.tags, + Fields.collections, + # If we don't list the following sub-fields _explicitly_, they're only sometimes searchable - that is, they + # are searchable only if at least one document in the index has a value. If we didn't list them here and, + # say, there were no tags.level3 tags in the index, the client would get an error if trying to search for + # these sub-fields: "Attribute `tags.level3` is not searchable." + Fields.tags + "." + Fields.tags_taxonomy, + Fields.tags + "." + Fields.tags_level0, + Fields.tags + "." + Fields.tags_level1, + Fields.tags + "." + Fields.tags_level2, + Fields.tags + "." + Fields.tags_level3, + Fields.collections + "." + Fields.collections_display_name, + Fields.collections + "." + Fields.collections_key, + Fields.published + "." + Fields.display_name, + Fields.published + "." + Fields.published_description, +] + +# Mark which attributes can be used for sorting search results: +INDEX_SORTABLE_ATTRIBUTES = [ + Fields.display_name, + Fields.created, + Fields.modified, + Fields.last_published, +] + +# Update the search ranking rules to let the (optional) "sort" parameter take precedence over keyword relevance. +INDEX_RANKING_RULES = [ + "sort", + "words", + "typo", + "proximity", + "attribute", + "exactness", +] diff --git a/openedx/core/djangoapps/content/search/management/commands/reindex_studio.py b/openedx/core/djangoapps/content/search/management/commands/reindex_studio.py index 3767ebcba6c9..2d8bb29f7a1f 100644 --- a/openedx/core/djangoapps/content/search/management/commands/reindex_studio.py +++ b/openedx/core/djangoapps/content/search/management/commands/reindex_studio.py @@ -18,8 +18,11 @@ class Command(BaseCommand): """ def add_arguments(self, parser): - parser.add_argument('--experimental', action='store_true') - parser.set_defaults(experimental=False) + parser.add_argument("--experimental", action="store_true") + parser.add_argument("--reset", action="store_true") + parser.add_argument("--init", action="store_true") + parser.add_argument("--incremental", action="store_true") + parser.set_defaults(experimental=False, reset=False, init=False, incremental=False) def handle(self, *args, **options): """ @@ -34,4 +37,11 @@ def handle(self, *args, **options): "Use the --experimental argument to acknowledge and run it." ) - api.rebuild_index(self.stdout.write) + if options["reset"]: + api.reset_index(self.stdout.write) + elif options["init"]: + api.init_index(self.stdout.write, self.stderr.write) + elif options["incremental"]: + api.rebuild_index(self.stdout.write, incremental=True) + else: + api.rebuild_index(self.stdout.write) diff --git a/openedx/core/djangoapps/content/search/migrations/0002_incrementalindexcompleted.py b/openedx/core/djangoapps/content/search/migrations/0002_incrementalindexcompleted.py new file mode 100644 index 000000000000..a316c35a7dfe --- /dev/null +++ b/openedx/core/djangoapps/content/search/migrations/0002_incrementalindexcompleted.py @@ -0,0 +1,21 @@ +# Generated by Django 4.2.16 on 2024-11-15 12:40 + +from django.db import migrations, models +import opaque_keys.edx.django.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('search', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='IncrementalIndexCompleted', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('context_key', opaque_keys.edx.django.models.LearningContextKeyField(max_length=255, unique=True)), + ], + ), + ] diff --git a/openedx/core/djangoapps/content/search/models.py b/openedx/core/djangoapps/content/search/models.py index 711c493ff895..6fa53ef17b34 100644 --- a/openedx/core/djangoapps/content/search/models.py +++ b/openedx/core/djangoapps/content/search/models.py @@ -65,3 +65,15 @@ def get_access_ids_for_request(request: Request, omit_orgs: list[str] = None) -> course_clause | library_clause ).order_by('-id').values_list("id", flat=True) ) + + +class IncrementalIndexCompleted(models.Model): + """ + Stores the contex keys of aleady indexed courses and libraries for incremental indexing. + """ + + context_key = LearningContextKeyField( + max_length=255, + unique=True, + null=False, + ) diff --git a/openedx/core/djangoapps/content/search/tests/test_api.py b/openedx/core/djangoapps/content/search/tests/test_api.py index c89d84490e96..d22f1e1a99b0 100644 --- a/openedx/core/djangoapps/content/search/tests/test_api.py +++ b/openedx/core/djangoapps/content/search/tests/test_api.py @@ -10,8 +10,10 @@ from opaque_keys.edx.keys import UsageKey import ddt +import pytest from django.test import override_settings from freezegun import freeze_time +from meilisearch.errors import MeilisearchApiError from openedx_learning.api import authoring as authoring_api from organizations.tests.factories import OrganizationFactory @@ -26,7 +28,7 @@ try: # This import errors in the lms because content.search is not an installed app there. from .. import api - from ..models import SearchAccess + from ..models import SearchAccess, IncrementalIndexCompleted except RuntimeError: SearchAccess = {} @@ -199,6 +201,9 @@ def setUp(self): "created": created_date.timestamp(), "modified": created_date.timestamp(), "access_id": lib_access.id, + "published": { + "num_children": 0 + }, "breadcrumbs": [{"display_name": "Library"}], } @@ -237,6 +242,87 @@ def test_reindex_meilisearch(self, mock_meilisearch): any_order=True, ) + @override_settings(MEILISEARCH_ENABLED=True) + def test_reindex_meilisearch_incremental(self, mock_meilisearch): + + # Add tags field to doc, since reindex calls includes tags + doc_sequential = copy.deepcopy(self.doc_sequential) + doc_sequential["tags"] = {} + doc_vertical = copy.deepcopy(self.doc_vertical) + doc_vertical["tags"] = {} + doc_problem1 = copy.deepcopy(self.doc_problem1) + doc_problem1["tags"] = {} + doc_problem1["collections"] = {"display_name": [], "key": []} + doc_problem2 = copy.deepcopy(self.doc_problem2) + doc_problem2["tags"] = {} + doc_problem2["collections"] = {"display_name": [], "key": []} + doc_collection = copy.deepcopy(self.collection_dict) + doc_collection["tags"] = {} + + api.rebuild_index(incremental=True) + assert mock_meilisearch.return_value.index.return_value.add_documents.call_count == 3 + mock_meilisearch.return_value.index.return_value.add_documents.assert_has_calls( + [ + call([doc_sequential, doc_vertical]), + call([doc_problem1, doc_problem2]), + call([doc_collection]), + ], + any_order=True, + ) + + # Now we simulate interruption by passing this function to the status_cb argument + def simulated_interruption(message): + # this exception prevents courses from being indexed + if "Indexing courses" in message: + raise Exception("Simulated interruption") + + with pytest.raises(Exception, match="Simulated interruption"): + api.rebuild_index(simulated_interruption, incremental=True) + + # two more calls due to collections + assert mock_meilisearch.return_value.index.return_value.add_documents.call_count == 5 + assert IncrementalIndexCompleted.objects.all().count() == 1 + api.rebuild_index(incremental=True) + assert IncrementalIndexCompleted.objects.all().count() == 0 + # one missing course indexed + assert mock_meilisearch.return_value.index.return_value.add_documents.call_count == 6 + + @override_settings(MEILISEARCH_ENABLED=True) + def test_reset_meilisearch_index(self, mock_meilisearch): + api.reset_index() + mock_meilisearch.return_value.swap_indexes.assert_called_once() + mock_meilisearch.return_value.create_index.assert_called_once() + mock_meilisearch.return_value.delete_index.call_count = 2 + api.reset_index() + mock_meilisearch.return_value.delete_index.call_count = 4 + + @override_settings(MEILISEARCH_ENABLED=True) + def test_init_meilisearch_index(self, mock_meilisearch): + # Test index already exists + api.init_index() + mock_meilisearch.return_value.swap_indexes.assert_not_called() + mock_meilisearch.return_value.create_index.assert_not_called() + mock_meilisearch.return_value.delete_index.assert_not_called() + + # Test index already exists and has no documents + mock_meilisearch.return_value.get_stats.return_value = 0 + api.init_index() + mock_meilisearch.return_value.swap_indexes.assert_not_called() + mock_meilisearch.return_value.create_index.assert_not_called() + mock_meilisearch.return_value.delete_index.assert_not_called() + + mock_meilisearch.return_value.get_index.side_effect = [ + MeilisearchApiError("Testing reindex", Mock(text='{"code":"index_not_found"}')), + MeilisearchApiError("Testing reindex", Mock(text='{"code":"index_not_found"}')), + Mock(created_at=1), + Mock(created_at=1), + Mock(created_at=1), + ] + api.init_index() + mock_meilisearch.return_value.swap_indexes.assert_called_once() + mock_meilisearch.return_value.create_index.assert_called_once() + mock_meilisearch.return_value.delete_index.call_count = 2 + @override_settings(MEILISEARCH_ENABLED=True) @patch( "openedx.core.djangoapps.content.search.api.searchable_doc_for_collection", @@ -473,6 +559,9 @@ def test_index_library_block_and_collections(self, mock_meilisearch): "created": created_date.timestamp(), "modified": created_date.timestamp(), "access_id": lib_access.id, + "published": { + "num_children": 0 + }, "breadcrumbs": [{"display_name": "Library"}], } doc_collection2_created = { @@ -488,6 +577,9 @@ def test_index_library_block_and_collections(self, mock_meilisearch): "created": created_date.timestamp(), "modified": created_date.timestamp(), "access_id": lib_access.id, + "published": { + "num_children": 0 + }, "breadcrumbs": [{"display_name": "Library"}], } doc_collection2_updated = { @@ -503,6 +595,9 @@ def test_index_library_block_and_collections(self, mock_meilisearch): "created": created_date.timestamp(), "modified": updated_date.timestamp(), "access_id": lib_access.id, + "published": { + "num_children": 0 + }, "breadcrumbs": [{"display_name": "Library"}], } doc_collection1_updated = { @@ -518,6 +613,9 @@ def test_index_library_block_and_collections(self, mock_meilisearch): "created": created_date.timestamp(), "modified": updated_date.timestamp(), "access_id": lib_access.id, + "published": { + "num_children": 0 + }, "breadcrumbs": [{"display_name": "Library"}], } doc_problem_with_collection1 = { diff --git a/openedx/core/djangoapps/content/search/tests/test_documents.py b/openedx/core/djangoapps/content/search/tests/test_documents.py index e0a604c24feb..603cc8d92f5e 100644 --- a/openedx/core/djangoapps/content/search/tests/test_documents.py +++ b/openedx/core/djangoapps/content/search/tests/test_documents.py @@ -443,5 +443,37 @@ def test_collection_with_library(self): 'tags': { 'taxonomy': ['Difficulty'], 'level0': ['Difficulty > Normal'] + }, + "published": { + "num_children": 0 + } + } + + def test_collection_with_published_library(self): + library_api.publish_changes(self.library.key) + + doc = searchable_doc_for_collection(self.library.key, self.collection.key) + doc.update(searchable_doc_tags_for_collection(self.library.key, self.collection.key)) + + assert doc == { + "id": "lib-collectionedx2012_falltoy_collection-d1d907a4", + "block_id": self.collection.key, + "usage_key": self.collection_usage_key, + "type": "collection", + "org": "edX", + "display_name": "Toy Collection", + "description": "my toy collection description", + "num_children": 1, + "context_key": "lib:edX:2012_Fall", + "access_id": self.library_access_id, + "breadcrumbs": [{"display_name": "some content_library"}], + "created": 1680674828.0, + "modified": 1680674828.0, + 'tags': { + 'taxonomy': ['Difficulty'], + 'level0': ['Difficulty > Normal'] + }, + "published": { + "num_children": 1 } } diff --git a/openedx/core/djangoapps/content_libraries/api.py b/openedx/core/djangoapps/content_libraries/api.py index 58b99a390fb2..6b1660262510 100644 --- a/openedx/core/djangoapps/content_libraries/api.py +++ b/openedx/core/djangoapps/content_libraries/api.py @@ -82,6 +82,7 @@ ContentLibraryData, LibraryBlockData, LibraryCollectionData, + ContentObjectChangedData, ) from openedx_events.content_authoring.signals import ( CONTENT_LIBRARY_CREATED, @@ -91,6 +92,7 @@ LIBRARY_BLOCK_DELETED, LIBRARY_BLOCK_UPDATED, LIBRARY_COLLECTION_UPDATED, + CONTENT_OBJECT_ASSOCIATIONS_CHANGED, ) from openedx_learning.api import authoring as authoring_api from openedx_learning.api.authoring_models import ( @@ -306,6 +308,13 @@ class LibraryXBlockType: # ============ +def user_can_create_library(user: AbstractUser) -> bool: + """ + Check if the user has permission to create a content library. + """ + return user.has_perm(permissions.CAN_CREATE_CONTENT_LIBRARY) + + def get_libraries_for_user(user, org=None, library_type=None, text_search=None, order=None): """ Return content libraries that the user has permission to view. @@ -698,7 +707,11 @@ def _get_library_component_tags_count(library_key) -> dict: return get_object_tag_counts(library_key_pattern, count_implicit=True) -def get_library_components(library_key, text_search=None, block_types=None) -> QuerySet[Component]: +def get_library_components( + library_key, + text_search=None, + block_types=None, +) -> QuerySet[Component]: """ Get the library components and filter. @@ -714,6 +727,7 @@ def get_library_components(library_key, text_search=None, block_types=None) -> Q type_names=block_types, draft_title=text_search, ) + return components @@ -1035,7 +1049,6 @@ def import_staged_content_from_user_clipboard(library_key: LibraryLocatorV2, use component_version.pk, content.id, key=filename, - learner_downloadable=True, ) # Emit library block created event @@ -1105,7 +1118,6 @@ def _create_component_for_block(content_lib, usage_key, user_id=None): component_version.pk, content.id, key="block.xml", - learner_downloadable=False ) return component_version @@ -1116,15 +1128,31 @@ def delete_library_block(usage_key, remove_from_parent=True): Delete the specified block from this library (soft delete). """ component = get_component_from_usage_key(usage_key) + library_key = usage_key.context_key + affected_collections = authoring_api.get_entity_collections(component.learning_package_id, component.key) + authoring_api.soft_delete_draft(component.pk) LIBRARY_BLOCK_DELETED.send_event( library_block=LibraryBlockData( - library_key=usage_key.context_key, + library_key=library_key, usage_key=usage_key ) ) + # For each collection, trigger LIBRARY_COLLECTION_UPDATED signal and set background=True to trigger + # collection indexing asynchronously. + # + # To delete the component on collections + for collection in affected_collections: + LIBRARY_COLLECTION_UPDATED.send_event( + library_collection=LibraryCollectionData( + library_key=library_key, + collection_key=collection.key, + background=True, + ) + ) + def get_library_block_static_asset_files(usage_key) -> list[LibraryXBlockStaticFile]: """ @@ -1359,6 +1387,39 @@ def revert_changes(library_key): ) ) + # For each collection, trigger LIBRARY_COLLECTION_UPDATED signal and set background=True to trigger + # collection indexing asynchronously. + # + # This is to update component counts in all library collections, + # because there may be components that have been discarded in the revert. + for collection in authoring_api.get_collections(learning_package.id): + LIBRARY_COLLECTION_UPDATED.send_event( + library_collection=LibraryCollectionData( + library_key=library_key, + collection_key=collection.key, + background=True, + ) + ) + + # Reindex components that are in collections + # + # Use case: When a component that was within a collection has been deleted + # and the changes are reverted, the component should appear in the + # collection again. + components_in_collections = authoring_api.get_components( + learning_package.id, draft=True, namespace='xblock.v1', + ).filter(publishable_entity__collections__isnull=False) + + for component in components_in_collections: + usage_key = library_component_usage_key(library_key, component) + + CONTENT_OBJECT_ASSOCIATIONS_CHANGED.send_event( + content_object=ContentObjectChangedData( + object_id=str(usage_key), + changes=["collections"], + ), + ) + def create_library_collection( library_key: LibraryLocatorV2, diff --git a/openedx/core/djangoapps/content_libraries/permissions.py b/openedx/core/djangoapps/content_libraries/permissions.py index 17671b5659f8..4e72381986ed 100644 --- a/openedx/core/djangoapps/content_libraries/permissions.py +++ b/openedx/core/djangoapps/content_libraries/permissions.py @@ -48,6 +48,12 @@ def is_studio_request(_): return settings.SERVICE_VARIANT == "cms" +@blanket_rule +def is_course_creator(user): + from cms.djangoapps.course_creators.views import get_course_creator_status + + return get_course_creator_status(user) == 'granted' + ########################### Permissions ########################### # Is the user allowed to view XBlocks from the specified content library @@ -68,7 +74,10 @@ def is_studio_request(_): # Is the user allowed to create content libraries? CAN_CREATE_CONTENT_LIBRARY = 'content_libraries.create_library' -perms[CAN_CREATE_CONTENT_LIBRARY] = is_user_active +if settings.FEATURES.get('ENABLE_CREATOR_GROUP', False): + perms[CAN_CREATE_CONTENT_LIBRARY] = is_global_staff | (is_user_active & is_course_creator) +else: + perms[CAN_CREATE_CONTENT_LIBRARY] = is_global_staff # Is the user allowed to view the specified content library in Studio, # including to view the raw OLX and asset files? @@ -76,8 +85,8 @@ def is_studio_request(_): perms[CAN_VIEW_THIS_CONTENT_LIBRARY] = is_user_active & ( # Global staff can access any library is_global_staff | - # Some libraries allow anyone to view them in Studio: - Attribute('allow_public_read', True) | + # Libraries with "public read" permissions can be accessed only by course creators + (Attribute('allow_public_read', True) & is_course_creator) | # Otherwise the user must be part of the library's team has_explicit_read_permission_for_library ) diff --git a/openedx/core/djangoapps/content_libraries/tests/base.py b/openedx/core/djangoapps/content_libraries/tests/base.py index 33d5477da698..1756f72ef74c 100644 --- a/openedx/core/djangoapps/content_libraries/tests/base.py +++ b/openedx/core/djangoapps/content_libraries/tests/base.py @@ -71,7 +71,7 @@ class ContentLibrariesRestApiTest(APITransactionTestCase): def setUp(self): super().setUp() - self.user = UserFactory.create(username="Bob", email="bob@example.com", password="edx") + self.user = UserFactory.create(username="Bob", email="bob@example.com", password="edx", is_staff=True) # Create an organization self.organization, _ = Organization.objects.get_or_create( short_name="CL-TEST", diff --git a/openedx/core/djangoapps/content_libraries/tests/test_api.py b/openedx/core/djangoapps/content_libraries/tests/test_api.py index c526e7b1a1f3..7be3e592ba9d 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_api.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_api.py @@ -561,3 +561,155 @@ def test_set_library_component_collections(self): }, collection_update_event_receiver.call_args_list[1].kwargs, ) + + def test_delete_library_block(self): + api.update_library_collection_components( + self.lib1.library_key, + self.col1.key, + usage_keys=[ + UsageKey.from_string(self.lib1_problem_block["id"]), + UsageKey.from_string(self.lib1_html_block["id"]), + ], + ) + + event_receiver = mock.Mock() + LIBRARY_COLLECTION_UPDATED.connect(event_receiver) + + api.delete_library_block(UsageKey.from_string(self.lib1_problem_block["id"])) + + assert event_receiver.call_count == 1 + self.assertDictContainsSubset( + { + "signal": LIBRARY_COLLECTION_UPDATED, + "sender": None, + "library_collection": LibraryCollectionData( + self.lib1.library_key, + collection_key=self.col1.key, + background=True, + ), + }, + event_receiver.call_args_list[0].kwargs, + ) + + def test_add_component_and_revert(self): + # Add component and publish + api.update_library_collection_components( + self.lib1.library_key, + self.col1.key, + usage_keys=[ + UsageKey.from_string(self.lib1_problem_block["id"]), + ], + ) + api.publish_changes(self.lib1.library_key) + + # Add component and revert + api.update_library_collection_components( + self.lib1.library_key, + self.col1.key, + usage_keys=[ + UsageKey.from_string(self.lib1_html_block["id"]), + ], + ) + + event_receiver = mock.Mock() + CONTENT_OBJECT_ASSOCIATIONS_CHANGED.connect(event_receiver) + collection_update_event_receiver = mock.Mock() + LIBRARY_COLLECTION_UPDATED.connect(collection_update_event_receiver) + + api.revert_changes(self.lib1.library_key) + + assert collection_update_event_receiver.call_count == 1 + assert event_receiver.call_count == 2 + self.assertDictContainsSubset( + { + "signal": LIBRARY_COLLECTION_UPDATED, + "sender": None, + "library_collection": LibraryCollectionData( + self.lib1.library_key, + collection_key=self.col1.key, + background=True, + ), + }, + collection_update_event_receiver.call_args_list[0].kwargs, + ) + self.assertDictContainsSubset( + { + "signal": CONTENT_OBJECT_ASSOCIATIONS_CHANGED, + "sender": None, + "content_object": ContentObjectChangedData( + object_id=str(self.lib1_problem_block["id"]), + changes=["collections"], + ), + }, + event_receiver.call_args_list[0].kwargs, + ) + self.assertDictContainsSubset( + { + "signal": CONTENT_OBJECT_ASSOCIATIONS_CHANGED, + "sender": None, + "content_object": ContentObjectChangedData( + object_id=str(self.lib1_html_block["id"]), + changes=["collections"], + ), + }, + event_receiver.call_args_list[1].kwargs, + ) + + def test_delete_component_and_revert(self): + # Add components and publish + api.update_library_collection_components( + self.lib1.library_key, + self.col1.key, + usage_keys=[ + UsageKey.from_string(self.lib1_problem_block["id"]), + UsageKey.from_string(self.lib1_html_block["id"]) + ], + ) + api.publish_changes(self.lib1.library_key) + + # Delete component and revert + api.delete_library_block(UsageKey.from_string(self.lib1_problem_block["id"])) + + event_receiver = mock.Mock() + CONTENT_OBJECT_ASSOCIATIONS_CHANGED.connect(event_receiver) + collection_update_event_receiver = mock.Mock() + LIBRARY_COLLECTION_UPDATED.connect(collection_update_event_receiver) + + api.revert_changes(self.lib1.library_key) + + assert collection_update_event_receiver.call_count == 1 + assert event_receiver.call_count == 2 + self.assertDictContainsSubset( + { + "signal": LIBRARY_COLLECTION_UPDATED, + "sender": None, + "library_collection": LibraryCollectionData( + self.lib1.library_key, + collection_key=self.col1.key, + background=True, + ), + }, + collection_update_event_receiver.call_args_list[0].kwargs, + ) + self.assertDictContainsSubset( + { + "signal": CONTENT_OBJECT_ASSOCIATIONS_CHANGED, + "sender": None, + "content_object": ContentObjectChangedData( + object_id=str(self.lib1_problem_block["id"]), + changes=["collections"], + ), + }, + event_receiver.call_args_list[0].kwargs, + ) + self.assertDictContainsSubset( + { + "signal": CONTENT_OBJECT_ASSOCIATIONS_CHANGED, + "sender": None, + "content_object": ContentObjectChangedData( + object_id=str(self.lib1_html_block["id"]), + changes=["collections"], + ), + }, + event_receiver.call_args_list[1].kwargs, + ) diff --git a/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py b/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py index 8977464dd4b6..34eb7c4f34fc 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py @@ -264,6 +264,8 @@ def test_library_blocks(self): # pylint: disable=too-many-statements Tests with some non-ASCII chars in slugs, titles, descriptions. """ + admin = UserFactory.create(username="Admin", email="admin@example.com", is_staff=True) + lib = self._create_library(slug="téstlꜟط", title="A Tést Lꜟطrary", description="Tésting XBlocks") lib_id = lib["id"] assert lib['has_unpublished_changes'] is False @@ -557,7 +559,7 @@ def test_library_permissions(self): # pylint: disable=too-many-statements Learning Core data models. """ # Create a few users to use for all of these tests: - admin = UserFactory.create(username="Admin", email="admin@example.com") + admin = UserFactory.create(username="Admin", email="admin@example.com", is_staff=True) author = UserFactory.create(username="Author", email="author@example.com") reader = UserFactory.create(username="Reader", email="reader@example.com") group = Group.objects.create(name="group1") @@ -679,14 +681,15 @@ def test_library_permissions(self): # pylint: disable=too-many-statements self._get_library_block_asset(block3_key, file_name="static/whatever.png", expect_response=403) # Nor can they preview the block: self._render_block_view(block3_key, view_name="student_view", expect_response=403) - # But if we grant allow_public_read, then they can: + # Even if we grant allow_public_read, then they can't: with self.as_user(admin): self._update_library(lib_id, allow_public_read=True) self._set_library_block_asset(block3_key, "static/whatever.png", b"data") with self.as_user(random_user): - self._get_library_block_olx(block3_key) + self._get_library_block_olx(block3_key, expect_response=403) + self._get_library_block_fields(block3_key, expect_response=403) + # But he can preview the block: self._render_block_view(block3_key, view_name="student_view") - f = self._get_library_block_fields(block3_key) # self._get_library_block_assets(block3_key) # self._get_library_block_asset(block3_key, file_name="whatever.png") @@ -728,7 +731,7 @@ def test_no_lockout(self): """ Test that administrators cannot be removed if they are the only administrator granted access. """ - admin = UserFactory.create(username="Admin", email="admin@example.com") + admin = UserFactory.create(username="Admin", email="admin@example.com", is_staff=True) successor = UserFactory.create(username="Successor", email="successor@example.com") with self.as_user(admin): lib = self._create_library(slug="permtest", title="Permission Test Library", description="Testing") @@ -1070,7 +1073,7 @@ def test_library_paste_clipboard(self): from openedx.core.djangoapps.content_staging.api import save_xblock_to_user_clipboard # Create user to perform tests on - author = UserFactory.create(username="Author", email="author@example.com") + author = UserFactory.create(username="Author", email="author@example.com", is_staff=True) with self.as_user(author): lib = self._create_library( slug="test_lib_paste_clipboard", @@ -1110,8 +1113,8 @@ def test_library_paste_clipboard(self): self._get_library_block_asset(pasted_usage_key, "static/hello.txt") # Compare the two text files - src_data = self.client.get(f"/library_assets/blocks/{usage_key}/static/hello.txt").content - dest_data = self.client.get(f"/library_assets/blocks/{pasted_usage_key}/static/hello.txt").content + src_data = self.client.get(f"/library_assets/blocks/{usage_key}/static/hello.txt").getvalue() + dest_data = self.client.get(f"/library_assets/blocks/{pasted_usage_key}/static/hello.txt").getvalue() assert src_data == dest_data # Check that the new block was created after the paste and it's content matches diff --git a/openedx/core/djangoapps/content_libraries/tests/test_runtime.py b/openedx/core/djangoapps/content_libraries/tests/test_runtime.py index f79808a7ec9a..35adb862601f 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_runtime.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_runtime.py @@ -2,12 +2,12 @@ Test the Learning-Core-based XBlock runtime and content libraries together. """ import json -from gettext import GNUTranslations -from django.test import TestCase from completion.test_utils import CompletionWaffleTestMixin from django.db import connections, transaction +from django.test import TestCase, override_settings from django.utils.text import slugify +import django.utils.translation from organizations.models import Organization from rest_framework.test import APIClient from xblock.core import XBlock @@ -141,18 +141,17 @@ def test_html_round_trip(self): assert olx_3 == olx_2 == canonical_olx -class ContentLibraryRuntimeTests(ContentLibraryContentTestMixin): +class ContentLibraryRuntimeTests(ContentLibraryContentTestMixin, TestCase): """ Basic tests of the Learning-Core-based XBlock runtime using XBlocks in a content library. """ - def test_dndv2_sets_translator(self): dnd_block_key = library_api.create_library_block(self.library.key, "drag-and-drop-v2", "dnd1").usage_key library_api.publish_changes(self.library.key) dnd_block = xblock_api.load_block(dnd_block_key, self.student_a) i18n_service = dnd_block.runtime.service(dnd_block, 'i18n') - assert isinstance(i18n_service.translator, GNUTranslations) + assert i18n_service.translator is django.utils.translation def test_has_score(self): """ @@ -170,8 +169,7 @@ def test_xblock_metadata(self): """ Test the XBlock metadata API """ - unit_block_key = library_api.create_library_block(self.library.key, "unit", "metadata-u1").usage_key - problem_key = library_api.create_library_block_child(unit_block_key, "problem", "metadata-p1").usage_key + problem_key = library_api.create_library_block(self.library.key, "problem", "metadata-p1").usage_key new_olx = """ @@ -193,14 +191,6 @@ def test_xblock_metadata(self): client = APIClient() client.login(username=self.student_a.username, password='edx') - # Check the metadata API for the unit: - metadata_view_result = client.get( - URL_BLOCK_METADATA_URL.format(block_key=unit_block_key), - {"include": "children,editable_children"}, - ) - assert metadata_view_result.data['children'] == [str(problem_key)] - assert metadata_view_result.data['editable_children'] == [str(problem_key)] - # Check the metadata API for the problem: metadata_view_result = client.get( URL_BLOCK_METADATA_URL.format(block_key=problem_key), @@ -227,8 +217,7 @@ def test_xblock_fields(self): client.login(username=self.staff_user.username, password='edx') # create/save a block using the library APIs first - unit_block_key = library_api.create_library_block(self.library.key, "unit", "fields-u1").usage_key - block_key = library_api.create_library_block_child(unit_block_key, "html", "fields-p1").usage_key + block_key = library_api.create_library_block(self.library.key, "html", "fields-p1").usage_key new_olx = """

This is some HTML.

@@ -251,14 +240,24 @@ def test_xblock_fields(self): } }, format='json') block_saved = xblock_api.load_block(block_key, self.staff_user) - assert block_saved.data == '\n

test

\n' + assert block_saved.data == '

test

' assert block_saved.display_name == 'New Display Name' +# EphemeralKeyValueStore requires a working cache, and the default test cache is a dummy cache. +@override_settings( + XBLOCK_RUNTIME_V2_EPHEMERAL_DATA_CACHE='default', + CACHES={ + 'default': { + 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', + 'LOCATION': 'edx_loc_mem_cache', + }, + }, +) # We can remove the line below to enable this in Studio once we implement a session-backed # field data store which we can use for both studio users and anonymous users @skip_unless_lms -class ContentLibraryXBlockUserStateTest(ContentLibraryContentTestMixin): +class ContentLibraryXBlockUserStateTest(ContentLibraryContentTestMixin, TestCase): """ Test that the Blockstore-based XBlock runtime can store and retrieve student state for XBlocks when learners access blocks directly in a library context, @@ -561,7 +560,7 @@ def test_i18n(self): @skip_unless_lms # No completion tracking in Studio -class ContentLibraryXBlockCompletionTest(ContentLibraryContentTestMixin, CompletionWaffleTestMixin): +class ContentLibraryXBlockCompletionTest(ContentLibraryContentTestMixin, CompletionWaffleTestMixin, TestCase): """ Test that the Blockstore-based XBlocks can track their completion status using the completion library. diff --git a/openedx/core/djangoapps/content_libraries/tests/test_static_assets.py b/openedx/core/djangoapps/content_libraries/tests/test_static_assets.py index b903a0ca977a..69f5cd2d797c 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_static_assets.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_static_assets.py @@ -168,12 +168,12 @@ def test_anonymous_user(self): response = self.client.get( f"/library_assets/component_versions/{self.draft_component_version.uuid}/static/test.svg" ) - assert response.status_code == 403 + assert response.status_code == 401 def test_unauthorized_user(self): """User who is not a Content Library staff should not have access.""" self.client.logout() - student = UserFactory.create( + UserFactory.create( username="student", email="student@example.com", password="student-pass", diff --git a/openedx/core/djangoapps/content_libraries/urls.py b/openedx/core/djangoapps/content_libraries/urls.py index 5bf36162d56d..857126eef7c9 100644 --- a/openedx/core/djangoapps/content_libraries/urls.py +++ b/openedx/core/djangoapps/content_libraries/urls.py @@ -79,12 +79,12 @@ path('library_assets/', include([ path( 'component_versions//', - views.component_version_asset, + views.LibraryComponentAssetView.as_view(), name='library-assets', ), path( 'blocks//', - views.component_draft_asset, + views.LibraryComponentDraftAssetView.as_view(), name='library-draft-assets', ), ]) diff --git a/openedx/core/djangoapps/content_libraries/views.py b/openedx/core/djangoapps/content_libraries/views.py index 9357d54ca7ce..d42dbb51d8d3 100644 --- a/openedx/core/djangoapps/content_libraries/views.py +++ b/openedx/core/djangoapps/content_libraries/views.py @@ -80,7 +80,6 @@ from django.utils.translation import gettext as _ from django.views.decorators.clickjacking import xframe_options_exempt from django.views.decorators.csrf import csrf_exempt -from django.views.decorators.http import require_safe from django.views.generic.base import TemplateResponseMixin, View from pylti1p3.contrib.django import DjangoCacheDataStorage, DjangoDbToolConf, DjangoMessageLaunch, DjangoOIDCLogin from pylti1p3.exception import LtiException, OIDCException @@ -716,6 +715,9 @@ class LibraryBlockOlxView(APIView): @convert_exceptions def get(self, request, usage_key_str): """ + DEPRECATED. Use get_block_olx_view() in xblock REST-API. + Can be removed post-Teak. + Get the block's OLX """ key = LibraryUsageLocatorV2.from_string(usage_key_str) @@ -1168,8 +1170,7 @@ def get(self, request): return JsonResponse(self.lti_tool_config.get_jwks(), safe=False) -@require_safe -def component_version_asset(request, component_version_uuid, asset_path): +def get_component_version_asset(request, component_version_uuid, asset_path): """ Serves static assets associated with particular Component versions. @@ -1206,7 +1207,6 @@ def component_version_asset(request, component_version_uuid, asset_path): component_version_uuid, asset_path, public=False, - learner_downloadable_only=False, ) # If there was any error, we return that response because it will have the @@ -1239,16 +1239,34 @@ def component_version_asset(request, component_version_uuid, asset_path): ) -@require_safe -def component_draft_asset(request, usage_key, asset_path): +@view_auth_classes() +class LibraryComponentAssetView(APIView): + """ + Serves static assets associated with particular Component versions. + """ + @convert_exceptions + def get(self, request, component_version_uuid, asset_path): + """ + GET API for fetching static asset for given component_version_uuid. + """ + return get_component_version_asset(request, component_version_uuid, asset_path) + + +@view_auth_classes() +class LibraryComponentDraftAssetView(APIView): """ Serves the draft version of static assets associated with a Library Component. - See `component_version_asset` for more details + See `get_component_version_asset` for more details """ - try: - component_version_uuid = api.get_component_from_usage_key(usage_key).versioning.draft.uuid - except ObjectDoesNotExist as exc: - raise Http404() from exc + @convert_exceptions + def get(self, request, usage_key, asset_path): + """ + Fetches component_version_uuid for given usage_key and returns component asset. + """ + try: + component_version_uuid = api.get_component_from_usage_key(usage_key).versioning.draft.uuid + except ObjectDoesNotExist as exc: + raise Http404() from exc - return component_version_asset(request, component_version_uuid, asset_path) + return get_component_version_asset(request, component_version_uuid, asset_path) diff --git a/openedx/core/djangoapps/content_tagging/rest_api/v1/tests/test_views.py b/openedx/core/djangoapps/content_tagging/rest_api/v1/tests/test_views.py index 463ea42d08e9..5a99868ece66 100644 --- a/openedx/core/djangoapps/content_tagging/rest_api/v1/tests/test_views.py +++ b/openedx/core/djangoapps/content_tagging/rest_api/v1/tests/test_views.py @@ -13,6 +13,7 @@ import ddt from django.contrib.auth import get_user_model from django.core.files.uploadedfile import SimpleUploadedFile +from edx_django_utils.cache import RequestCache from opaque_keys.edx.locator import BlockUsageLocator, CourseLocator, LibraryCollectionLocator from openedx_tagging.core.tagging.models import Tag, Taxonomy from openedx_tagging.core.tagging.models.system_defined import SystemDefinedTaxonomy @@ -34,7 +35,6 @@ from openedx.core.djangoapps.content_libraries.api import AccessLevel, create_library, set_library_user_permissions from openedx.core.djangoapps.content_tagging import api as tagging_api from openedx.core.djangoapps.content_tagging.models import TaxonomyOrg -from openedx.core.djangoapps.content_tagging.utils import rules_cache from openedx.core.djangolib.testing.utils import skip_unless_cms from ....tests.test_objecttag_export_helpers import TaggedCourseMixin @@ -289,8 +289,8 @@ def setUp(self): self._setUp_taxonomies() self._setUp_collection() - # Clear the rules cache in between test runs to keep query counts consistent. - rules_cache.clear() + # Clear all request caches in between test runs to keep query counts consistent. + RequestCache.clear_all_namespaces() @skip_unless_cms @@ -510,12 +510,12 @@ def test_create_taxonomy(self, user_attr: str, expected_status: int) -> None: @ddt.data( ('staff', 11), - ("content_creatorA", 16), - ("library_staffA", 16), - ("library_userA", 16), - ("instructorA", 16), - ("course_instructorA", 16), - ("course_staffA", 16), + ("content_creatorA", 17), + ("library_staffA", 17), + ("library_userA", 17), + ("instructorA", 17), + ("course_instructorA", 17), + ("course_staffA", 17), ) @ddt.unpack def test_list_taxonomy_query_count(self, user_attr: str, expected_queries: int): @@ -1879,16 +1879,16 @@ def test_get_copied_tags(self): ('staff', 'courseA', 8), ('staff', 'libraryA', 8), ('staff', 'collection_key', 8), - ("content_creatorA", 'courseA', 11, False), - ("content_creatorA", 'libraryA', 11, False), - ("content_creatorA", 'collection_key', 11, False), - ("library_staffA", 'libraryA', 11, False), # Library users can only view objecttags, not change them? - ("library_staffA", 'collection_key', 11, False), - ("library_userA", 'libraryA', 11, False), - ("library_userA", 'collection_key', 11, False), - ("instructorA", 'courseA', 11), - ("course_instructorA", 'courseA', 11), - ("course_staffA", 'courseA', 11), + ("content_creatorA", 'courseA', 12, False), + ("content_creatorA", 'libraryA', 12, False), + ("content_creatorA", 'collection_key', 12, False), + ("library_staffA", 'libraryA', 12, False), # Library users can only view objecttags, not change them? + ("library_staffA", 'collection_key', 12, False), + ("library_userA", 'libraryA', 12, False), + ("library_userA", 'collection_key', 12, False), + ("instructorA", 'courseA', 12), + ("course_instructorA", 'courseA', 12), + ("course_staffA", 'courseA', 12), ) @ddt.unpack def test_object_tags_query_count( diff --git a/openedx/core/djangoapps/discussions/config/waffle.py b/openedx/core/djangoapps/discussions/config/waffle.py index 1d4c67e9e17b..7b6395bb1c01 100644 --- a/openedx/core/djangoapps/discussions/config/waffle.py +++ b/openedx/core/djangoapps/discussions/config/waffle.py @@ -2,6 +2,7 @@ This module contains various configuration settings via waffle switches for the discussions app. """ + from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag WAFFLE_FLAG_NAMESPACE = "discussions" @@ -43,3 +44,19 @@ ENABLE_NEW_STRUCTURE_DISCUSSIONS = CourseWaffleFlag( f"{WAFFLE_FLAG_NAMESPACE}.enable_new_structure_discussions", __name__ ) + +# .. toggle_name: discussions.enable_forum_v2 +# .. toggle_implementation: CourseWaffleFlag +# .. toggle_default: False +# .. toggle_description: Waffle flag to use the forum v2 instead of v1(cs_comment_service) +# .. toggle_use_cases: temporary, open_edx +# .. toggle_creation_date: 2024-9-26 +# .. toggle_target_removal_date: 2025-12-05 +ENABLE_FORUM_V2 = CourseWaffleFlag(f"{WAFFLE_FLAG_NAMESPACE}.enable_forum_v2", __name__) + + +def is_forum_v2_enabled(course_id): + """ + Returns a boolean if forum V2 is enabled on the course + """ + return ENABLE_FORUM_V2.is_enabled(course_id) diff --git a/openedx/core/djangoapps/django_comment_common/comment_client/comment.py b/openedx/core/djangoapps/django_comment_common/comment_client/comment.py index c86f7eb40515..39b8680b6377 100644 --- a/openedx/core/djangoapps/django_comment_common/comment_client/comment.py +++ b/openedx/core/djangoapps/django_comment_common/comment_client/comment.py @@ -4,7 +4,9 @@ from openedx.core.djangoapps.django_comment_common.comment_client import models, settings from .thread import Thread, _url_for_flag_abuse_thread, _url_for_unflag_abuse_thread -from .utils import CommentClientRequestError, perform_request +from .utils import CommentClientRequestError, get_course_key, perform_request +from forum import api as forum_api +from openedx.core.djangoapps.discussions.config.waffle import is_forum_v2_enabled class Comment(models.Model): @@ -68,14 +70,25 @@ def flagAbuse(self, user, voteable): url = _url_for_flag_abuse_comment(voteable.id) else: raise CommentClientRequestError("Can only flag/unflag threads or comments") - params = {'user_id': user.id} - response = perform_request( - 'put', - url, - params, - metric_tags=self._metric_tags, - metric_action='comment.abuse.flagged' - ) + course_key = get_course_key(self.attributes.get("course_id")) + if is_forum_v2_enabled(course_key): + if voteable.type == 'thread': + response = forum_api.update_thread_flag( + voteable.id, "flag", user_id=user.id, course_id=str(course_key) + ) + else: + response = forum_api.update_comment_flag( + voteable.id, "flag", user_id=user.id, course_id=str(course_key) + ) + else: + params = {'user_id': user.id} + response = perform_request( + 'put', + url, + params, + metric_tags=self._metric_tags, + metric_action='comment.abuse.flagged' + ) voteable._update_from_response(response) def unFlagAbuse(self, user, voteable, removeAll): @@ -85,18 +98,37 @@ def unFlagAbuse(self, user, voteable, removeAll): url = _url_for_unflag_abuse_comment(voteable.id) else: raise CommentClientRequestError("Can flag/unflag for threads or comments") - params = {'user_id': user.id} - - if removeAll: - params['all'] = True - - response = perform_request( - 'put', - url, - params, - metric_tags=self._metric_tags, - metric_action='comment.abuse.unflagged' - ) + course_key = get_course_key(self.attributes.get("course_id")) + if is_forum_v2_enabled(course_key): + if voteable.type == "thread": + response = forum_api.update_thread_flag( + thread_id=voteable.id, + action="unflag", + user_id=user.id, + update_all=bool(removeAll), + course_id=str(course_key) + ) + else: + response = forum_api.update_comment_flag( + comment_id=voteable.id, + action="unflag", + user_id=user.id, + update_all=bool(removeAll), + course_id=str(course_key) + ) + else: + params = {'user_id': user.id} + + if removeAll: + params['all'] = True + + response = perform_request( + 'put', + url, + params, + metric_tags=self._metric_tags, + metric_action='comment.abuse.unflagged' + ) voteable._update_from_response(response) @property diff --git a/openedx/core/djangoapps/django_comment_common/comment_client/course.py b/openedx/core/djangoapps/django_comment_common/comment_client/course.py index 67d7efd22838..8cbb580e7831 100644 --- a/openedx/core/djangoapps/django_comment_common/comment_client/course.py +++ b/openedx/core/djangoapps/django_comment_common/comment_client/course.py @@ -7,8 +7,10 @@ from edx_django_utils.monitoring import function_trace from opaque_keys.edx.keys import CourseKey +from forum import api as forum_api from openedx.core.djangoapps.django_comment_common.comment_client import settings from openedx.core.djangoapps.django_comment_common.comment_client.utils import perform_request +from openedx.core.djangoapps.discussions.config.waffle import is_forum_v2_enabled def get_course_commentable_counts(course_key: CourseKey) -> Dict[str, Dict[str, int]]: @@ -29,17 +31,20 @@ def get_course_commentable_counts(course_key: CourseKey) -> Dict[str, Dict[str, } """ - url = f"{settings.PREFIX}/commentables/{course_key}/counts" - response = perform_request( - 'get', - url, - metric_tags=[ - f"course_key:{course_key}", - "function:get_course_commentable_counts", - ], - metric_action='commentable_stats.retrieve', - ) - return response + if is_forum_v2_enabled(course_key): + commentable_stats = forum_api.get_commentables_stats(str(course_key)) + else: + url = f"{settings.PREFIX}/commentables/{course_key}/counts" + commentable_stats = perform_request( + 'get', + url, + metric_tags=[ + f"course_key:{course_key}", + "function:get_course_commentable_counts", + ], + metric_action='commentable_stats.retrieve', + ) + return commentable_stats @function_trace("get_course_user_stats") @@ -76,17 +81,21 @@ def get_course_user_stats(course_key: CourseKey, params: Optional[Dict] = None) """ if params is None: params = {} - url = f"{settings.PREFIX}/users/{course_key}/stats" - return perform_request( - 'get', - url, - params, - metric_action='user.course_stats', - metric_tags=[ - f"course_key:{course_key}", - "function:get_course_user_stats", - ], - ) + if is_forum_v2_enabled(course_key): + course_stats = forum_api.get_user_course_stats(str(course_key), **params) + else: + url = f"{settings.PREFIX}/users/{course_key}/stats" + course_stats = perform_request( + 'get', + url, + params, + metric_action='user.course_stats', + metric_tags=[ + f"course_key:{course_key}", + "function:get_course_user_stats", + ], + ) + return course_stats @function_trace("update_course_users_stats") @@ -100,13 +109,17 @@ def update_course_users_stats(course_key: CourseKey) -> Dict: Returns: dict: data returned by API. Contains count of users updated. """ - url = f"{settings.PREFIX}/users/{course_key}/update_stats" - return perform_request( - 'post', - url, - metric_action='user.update_course_stats', - metric_tags=[ - f"course_key:{course_key}", - "function:update_course_users_stats", - ], - ) + if is_forum_v2_enabled(course_key): + course_stats = forum_api.update_users_in_course(str(course_key)) + else: + url = f"{settings.PREFIX}/users/{course_key}/update_stats" + course_stats = perform_request( + 'post', + url, + metric_action='user.update_course_stats', + metric_tags=[ + f"course_key:{course_key}", + "function:update_course_users_stats", + ], + ) + return course_stats diff --git a/openedx/core/djangoapps/django_comment_common/comment_client/models.py b/openedx/core/djangoapps/django_comment_common/comment_client/models.py index 4e602809c82a..31256eb64735 100644 --- a/openedx/core/djangoapps/django_comment_common/comment_client/models.py +++ b/openedx/core/djangoapps/django_comment_common/comment_client/models.py @@ -3,7 +3,9 @@ import logging -from .utils import CommentClientRequestError, extract, perform_request +from .utils import CommentClientRequestError, extract, perform_request, get_course_key +from forum import api as forum_api +from openedx.core.djangoapps.discussions.config.waffle import is_forum_v2_enabled log = logging.getLogger(__name__) @@ -69,14 +71,25 @@ def retrieve(self, *args, **kwargs): return self def _retrieve(self, *args, **kwargs): - url = self.url(action='get', params=self.attributes) - response = perform_request( - 'get', - url, - self.default_retrieve_params, - metric_tags=self._metric_tags, - metric_action='model.retrieve' - ) + course_id = self.attributes.get("course_id") or kwargs.get("course_id") + if not course_id: + course_id = forum_api.get_course_id_by_comment(self.id) + course_key = get_course_key(course_id) + response = None + if is_forum_v2_enabled(course_key): + if self.type == "comment": + response = forum_api.get_parent_comment(comment_id=self.attributes["id"], course_id=str(course_key)) + if response is None: + raise CommentClientRequestError("Forum v2 API call is missing") + else: + url = self.url(action='get', params=self.attributes) + response = perform_request( + 'get', + url, + self.default_retrieve_params, + metric_tags=self._metric_tags, + metric_action='model.retrieve' + ) self._update_from_response(response) @property @@ -151,33 +164,27 @@ def save(self, params=None): """ self.before_save(self) if self.id: # if we have id already, treat this as an update - request_params = self.updatable_attributes() - if params: - request_params.update(params) - url = self.url(action='put', params=self.attributes) - response = perform_request( - 'put', - url, - request_params, - metric_tags=self._metric_tags, - metric_action='model.update' - ) - else: # otherwise, treat this as an insert - url = self.url(action='post', params=self.attributes) - response = perform_request( - 'post', - url, - self.initializable_attributes(), - metric_tags=self._metric_tags, - metric_action='model.insert' - ) + response = self.handle_update(params) + else: # otherwise, treat this as an insert + response = self.handle_create(params) + self.retrieved = True self._update_from_response(response) self.after_save(self) def delete(self): - url = self.url(action='delete', params=self.attributes) - response = perform_request('delete', url, metric_tags=self._metric_tags, metric_action='model.delete') + course_key = get_course_key(self.attributes.get("course_id")) + if is_forum_v2_enabled(course_key): + response = None + if self.type == "comment": + response = forum_api.delete_comment(comment_id=self.attributes["id"], course_id=str(course_key)) + elif self.type == "thread": + response = forum_api.delete_thread(thread_id=self.attributes["id"], course_id=str(course_key)) + if response is None: + raise CommentClientRequestError("Forum v2 API call is missing") + else: + url = self.url(action='delete', params=self.attributes) + response = perform_request('delete', url, metric_tags=self._metric_tags, metric_action='model.delete') self.retrieved = True self._update_from_response(response) @@ -208,3 +215,157 @@ def url(cls, action, params=None): raise CommentClientRequestError(f"Cannot perform action {action} without id") # lint-amnesty, pylint: disable=raise-missing-from else: # action must be in DEFAULT_ACTIONS_WITHOUT_ID now return cls.url_without_id() + + def handle_update(self, params=None): + request_params = self.updatable_attributes() + if params: + request_params.update(params) + course_id = self.attributes.get("course_id") or request_params.get("course_id") + course_key = get_course_key(course_id) + if is_forum_v2_enabled(course_key): + response = None + if self.type == "comment": + response = self.handle_update_comment(request_params, str(course_key)) + elif self.type == "thread": + response = self.handle_update_thread(request_params, str(course_key)) + elif self.type == "user": + response = self.handle_update_user(request_params, str(course_key)) + if response is None: + raise CommentClientRequestError("Forum v2 API call is missing") + else: + response = self.perform_http_put_request(request_params) + return response + + def handle_update_user(self, request_params, course_id): + try: + username = request_params["username"] + external_id = str(request_params["external_id"]) + except KeyError as e: + raise e + response = forum_api.update_user( + external_id, + username=username, + course_id=course_id, + ) + return response + + def handle_update_comment(self, request_params, course_id): + request_data = { + "comment_id": self.attributes["id"], + "body": request_params.get("body"), + "course_id": request_params.get("course_id"), + "user_id": request_params.get("user_id"), + "anonymous": request_params.get("anonymous"), + "anonymous_to_peers": request_params.get("anonymous_to_peers"), + "endorsed": request_params.get("endorsed"), + "closed": request_params.get("closed"), + "editing_user_id": request_params.get("editing_user_id"), + "edit_reason_code": request_params.get("edit_reason_code"), + "endorsement_user_id": request_params.get("endorsement_user_id"), + "course_key": course_id + } + request_data = {k: v for k, v in request_data.items() if v is not None} + response = forum_api.update_comment(**request_data) + return response + + def handle_update_thread(self, request_params, course_id): + request_data = { + "thread_id": self.attributes["id"], + "title": request_params.get("title"), + "body": request_params.get("body"), + "course_id": request_params.get("course_id"), + "anonymous": request_params.get("anonymous"), + "anonymous_to_peers": request_params.get("anonymous_to_peers"), + "closed": request_params.get("closed"), + "commentable_id": request_params.get("commentable_id"), + "user_id": request_params.get("user_id"), + "editing_user_id": request_params.get("editing_user_id"), + "pinned": request_params.get("pinned"), + "thread_type": request_params.get("thread_type"), + "edit_reason_code": request_params.get("edit_reason_code"), + "close_reason_code": request_params.get("close_reason_code"), + "closing_user_id": request_params.get("closing_user_id"), + "endorsed": request_params.get("endorsed"), + "course_key": course_id + } + request_data = {k: v for k, v in request_data.items() if v is not None} + response = forum_api.update_thread(**request_data) + return response + + def perform_http_put_request(self, request_params): + url = self.url(action="put", params=self.attributes) + response = perform_request( + "put", + url, + request_params, + metric_tags=self._metric_tags, + metric_action="model.update", + ) + return response + + def perform_http_post_request(self): + url = self.url(action="post", params=self.attributes) + response = perform_request( + "post", + url, + self.initializable_attributes(), + metric_tags=self._metric_tags, + metric_action="model.insert", + ) + return response + + def handle_create(self, params=None): + course_id = self.attributes.get("course_id") or params.get("course_id") + course_key = get_course_key(course_id) + if is_forum_v2_enabled(course_key): + response = None + if self.type == "comment": + response = self.handle_create_comment(str(course_key)) + elif self.type == "thread": + response = self.handle_create_thread(str(course_key)) + if response is None: + raise CommentClientRequestError("Forum v2 API call is missing") + else: + response = self.perform_http_post_request() + return response + + def handle_create_comment(self, course_id): + request_data = self.initializable_attributes() + body = request_data["body"] + user_id = request_data["user_id"] + course_id = course_id or str(request_data["course_id"]) + if parent_id := self.attributes.get("parent_id"): + response = forum_api.create_child_comment( + parent_id, + body, + user_id, + course_id, + request_data.get("anonymous", False), + request_data.get("anonymous_to_peers", False), + ) + else: + response = forum_api.create_parent_comment( + self.attributes["thread_id"], + body, + user_id, + course_id, + request_data.get("anonymous", False), + request_data.get("anonymous_to_peers", False), + ) + return response + + def handle_create_thread(self, course_id): + request_data = self.initializable_attributes() + response = forum_api.create_thread( + title=request_data["title"], + body=request_data["body"], + course_id=course_id or str(request_data["course_id"]), + user_id=str(request_data["user_id"]), + anonymous=request_data.get("anonymous", False), + anonymous_to_peers=request_data.get("anonymous_to_peers", False), + commentable_id=request_data.get("commentable_id", "course"), + thread_type=request_data.get("thread_type", "discussion"), + group_id=request_data.get("group_id", None), + context=request_data.get("context", None), + ) + return response diff --git a/openedx/core/djangoapps/django_comment_common/comment_client/subscriptions.py b/openedx/core/djangoapps/django_comment_common/comment_client/subscriptions.py index 545948a092cc..2130dfc56be6 100644 --- a/openedx/core/djangoapps/django_comment_common/comment_client/subscriptions.py +++ b/openedx/core/djangoapps/django_comment_common/comment_client/subscriptions.py @@ -4,6 +4,8 @@ import logging from . import models, settings, utils +from forum import api as forum_api +from openedx.core.djangoapps.discussions.config.waffle import is_forum_v2_enabled log = logging.getLogger(__name__) @@ -21,7 +23,7 @@ class Subscription(models.Model): base_url = f"{settings.PREFIX}/threads" @classmethod - def fetch(cls, thread_id, query_params): + def fetch(cls, thread_id, course_id, query_params): """ Fetches the subscriptions for a given thread_id """ @@ -33,14 +35,23 @@ def fetch(cls, thread_id, query_params): params.update( utils.strip_blank(utils.strip_none(query_params)) ) - response = utils.perform_request( - 'get', - cls.url(action='get', params=params) + "/subscriptions", - params, - metric_tags=[], - metric_action='subscription.get', - paged_results=True - ) + course_key = utils.get_course_key(course_id) + if is_forum_v2_enabled(course_key): + response = forum_api.get_thread_subscriptions( + thread_id=thread_id, + page=params["page"], + per_page=params["per_page"], + course_id=str(course_key) + ) + else: + response = utils.perform_request( + 'get', + cls.url(action='get', params=params) + "/subscriptions", + params, + metric_tags=[], + metric_action='subscription.get', + paged_results=True + ) return utils.SubscriptionsPaginatedResult( collection=response.get('collection', []), page=response.get('page', 1), diff --git a/openedx/core/djangoapps/django_comment_common/comment_client/thread.py b/openedx/core/djangoapps/django_comment_common/comment_client/thread.py index ef5accbad25d..895a28c73993 100644 --- a/openedx/core/djangoapps/django_comment_common/comment_client/thread.py +++ b/openedx/core/djangoapps/django_comment_common/comment_client/thread.py @@ -6,6 +6,8 @@ from eventtracking import tracker from . import models, settings, utils +from forum import api as forum_api +from openedx.core.djangoapps.discussions.config.waffle import is_forum_v2_enabled log = logging.getLogger(__name__) @@ -59,14 +61,35 @@ def search(cls, query_params): url = cls.url(action='get_all', params=utils.extract(params, 'commentable_id')) if params.get('commentable_id'): del params['commentable_id'] - response = utils.perform_request( - 'get', - url, - params, - metric_tags=['course_id:{}'.format(query_params['course_id'])], - metric_action='thread.search', - paged_results=True - ) + + if is_forum_v2_enabled(utils.get_course_key(query_params['course_id'])): + if query_params.get('text'): + search_params = utils.strip_none(params) + if user_id := search_params.get('user_id'): + search_params['user_id'] = str(user_id) + if group_ids := search_params.get('group_ids'): + search_params['group_ids'] = [int(group_id) for group_id in group_ids.split(',')] + elif group_id := search_params.get('group_id'): + search_params['group_ids'] = [int(group_id)] + search_params.pop('group_id', None) + if commentable_ids := search_params.get('commentable_ids'): + search_params['commentable_ids'] = commentable_ids.split(',') + elif commentable_id := search_params.get('commentable_id'): + search_params['commentable_ids'] = [commentable_id] + search_params.pop('commentable_id', None) + response = forum_api.search_threads(**search_params) + else: + response = forum_api.get_user_threads(**params) + else: + response = utils.perform_request( + 'get', + url, + params, + metric_tags=['course_id:{}'.format(query_params['course_id'])], + metric_action='thread.search', + paged_results=True + ) + if query_params.get('text'): search_query = query_params['text'] course_id = query_params['course_id'] @@ -148,14 +171,26 @@ def _retrieve(self, *args, **kwargs): 'merge_question_type_responses': kwargs.get('merge_question_type_responses', False) } request_params = utils.strip_none(request_params) - - response = utils.perform_request( - 'get', - url, - request_params, - metric_action='model.retrieve', - metric_tags=self._metric_tags - ) + course_id = kwargs.get("course_id") + if not course_id: + course_id = forum_api.get_course_id_by_thread(self.id) + course_key = utils.get_course_key(course_id) + if is_forum_v2_enabled(course_key): + if user_id := request_params.get('user_id'): + request_params['user_id'] = str(user_id) + response = forum_api.get_thread( + thread_id=self.id, + params=request_params, + course_id=str(course_key) + ) + else: + response = utils.perform_request( + 'get', + url, + request_params, + metric_action='model.retrieve', + metric_tags=self._metric_tags + ) self._update_from_response(response) def flagAbuse(self, user, voteable): @@ -163,14 +198,18 @@ def flagAbuse(self, user, voteable): url = _url_for_flag_abuse_thread(voteable.id) else: raise utils.CommentClientRequestError("Can only flag/unflag threads or comments") - params = {'user_id': user.id} - response = utils.perform_request( - 'put', - url, - params, - metric_action='thread.abuse.flagged', - metric_tags=self._metric_tags - ) + course_key = utils.get_course_key(self.attributes.get("course_id")) + if is_forum_v2_enabled(course_key): + response = forum_api.update_thread_flag(voteable.id, "flag", user_id=user.id, course_id=str(course_key)) + else: + params = {'user_id': user.id} + response = utils.perform_request( + 'put', + url, + params, + metric_action='thread.abuse.flagged', + metric_tags=self._metric_tags + ) voteable._update_from_response(response) def unFlagAbuse(self, user, voteable, removeAll): @@ -178,42 +217,68 @@ def unFlagAbuse(self, user, voteable, removeAll): url = _url_for_unflag_abuse_thread(voteable.id) else: raise utils.CommentClientRequestError("Can only flag/unflag for threads or comments") - params = {'user_id': user.id} - #if you're an admin, when you unflag, remove ALL flags - if removeAll: - params['all'] = True - - response = utils.perform_request( - 'put', - url, - params, - metric_tags=self._metric_tags, - metric_action='thread.abuse.unflagged' - ) + course_key = utils.get_course_key(self.attributes.get("course_id")) + if is_forum_v2_enabled(course_key): + response = forum_api.update_thread_flag( + thread_id=voteable.id, + action="unflag", + user_id=user.id, + update_all=bool(removeAll), + course_id=str(course_key) + ) + else: + params = {'user_id': user.id} + #if you're an admin, when you unflag, remove ALL flags + if removeAll: + params['all'] = True + + response = utils.perform_request( + 'put', + url, + params, + metric_tags=self._metric_tags, + metric_action='thread.abuse.unflagged' + ) voteable._update_from_response(response) def pin(self, user, thread_id): - url = _url_for_pin_thread(thread_id) - params = {'user_id': user.id} - response = utils.perform_request( - 'put', - url, - params, - metric_tags=self._metric_tags, - metric_action='thread.pin' - ) + course_key = utils.get_course_key(self.attributes.get("course_id")) + if is_forum_v2_enabled(course_key): + response = forum_api.pin_thread( + user_id=user.id, + thread_id=thread_id, + course_id=str(course_key) + ) + else: + url = _url_for_pin_thread(thread_id) + params = {'user_id': user.id} + response = utils.perform_request( + 'put', + url, + params, + metric_tags=self._metric_tags, + metric_action='thread.pin' + ) self._update_from_response(response) def un_pin(self, user, thread_id): - url = _url_for_un_pin_thread(thread_id) - params = {'user_id': user.id} - response = utils.perform_request( - 'put', - url, - params, - metric_tags=self._metric_tags, - metric_action='thread.unpin' - ) + course_key = utils.get_course_key(self.attributes.get("course_id")) + if is_forum_v2_enabled(course_key): + response = forum_api.unpin_thread( + user_id=user.id, + thread_id=thread_id, + course_id=str(course_key) + ) + else: + url = _url_for_un_pin_thread(thread_id) + params = {'user_id': user.id} + response = utils.perform_request( + 'put', + url, + params, + metric_tags=self._metric_tags, + metric_action='thread.unpin' + ) self._update_from_response(response) diff --git a/openedx/core/djangoapps/django_comment_common/comment_client/user.py b/openedx/core/djangoapps/django_comment_common/comment_client/user.py index 684469c9e787..2de4fbbfa95a 100644 --- a/openedx/core/djangoapps/django_comment_common/comment_client/user.py +++ b/openedx/core/djangoapps/django_comment_common/comment_client/user.py @@ -1,8 +1,10 @@ # pylint: disable=missing-docstring,protected-access """ User model wrapper for comment service""" - from . import models, settings, utils +from forum import api as forum_api +from forum.utils import ForumV2RequestError, str_to_bool +from openedx.core.djangoapps.discussions.config.waffle import is_forum_v2_enabled class User(models.Model): @@ -34,34 +36,55 @@ def read(self, source): """ Calls cs_comments_service to mark thread as read for the user """ - params = {'source_type': source.type, 'source_id': source.id} - utils.perform_request( - 'post', - _url_for_read(self.id), - params, - metric_action='user.read', - metric_tags=self._metric_tags + [f'target.type:{source.type}'], - ) + course_id = self.attributes.get("course_id") + course_key = utils.get_course_key(course_id) + if is_forum_v2_enabled(course_key): + forum_api.mark_thread_as_read(self.id, source.id, course_id=str(course_id)) + else: + params = {'source_type': source.type, 'source_id': source.id} + utils.perform_request( + 'post', + _url_for_read(self.id), + params, + metric_action='user.read', + metric_tags=self._metric_tags + [f'target.type:{source.type}'], + ) def follow(self, source): - params = {'source_type': source.type, 'source_id': source.id} - utils.perform_request( - 'post', - _url_for_subscription(self.id), - params, - metric_action='user.follow', - metric_tags=self._metric_tags + [f'target.type:{source.type}'], - ) + course_key = utils.get_course_key(self.attributes.get("course_id")) + if is_forum_v2_enabled(course_key): + forum_api.create_subscription( + user_id=self.id, + source_id=source.id, + course_id=str(course_key) + ) + else: + params = {'source_type': source.type, 'source_id': source.id} + utils.perform_request( + 'post', + _url_for_subscription(self.id), + params, + metric_action='user.follow', + metric_tags=self._metric_tags + [f'target.type:{source.type}'], + ) def unfollow(self, source): - params = {'source_type': source.type, 'source_id': source.id} - utils.perform_request( - 'delete', - _url_for_subscription(self.id), - params, - metric_action='user.unfollow', - metric_tags=self._metric_tags + [f'target.type:{source.type}'], - ) + course_key = utils.get_course_key(self.attributes.get("course_id")) + if is_forum_v2_enabled(course_key): + forum_api.delete_subscription( + user_id=self.id, + source_id=source.id, + course_id=str(course_key) + ) + else: + params = {'source_type': source.type, 'source_id': source.id} + utils.perform_request( + 'delete', + _url_for_subscription(self.id), + params, + metric_action='user.unfollow', + metric_tags=self._metric_tags + [f'target.type:{source.type}'], + ) def vote(self, voteable, value): if voteable.type == 'thread': @@ -70,14 +93,31 @@ def vote(self, voteable, value): url = _url_for_vote_comment(voteable.id) else: raise utils.CommentClientRequestError("Can only vote / unvote for threads or comments") - params = {'user_id': self.id, 'value': value} - response = utils.perform_request( - 'put', - url, - params, - metric_action='user.vote', - metric_tags=self._metric_tags + [f'target.type:{voteable.type}'], - ) + course_key = utils.get_course_key(self.attributes.get("course_id")) + if is_forum_v2_enabled(course_key): + if voteable.type == 'thread': + response = forum_api.update_thread_votes( + thread_id=voteable.id, + user_id=self.id, + value=value, + course_id=str(course_key) + ) + else: + response = forum_api.update_comment_votes( + comment_id=voteable.id, + user_id=self.id, + value=value, + course_id=str(course_key) + ) + else: + params = {'user_id': self.id, 'value': value} + response = utils.perform_request( + 'put', + url, + params, + metric_action='user.vote', + metric_tags=self._metric_tags + [f'target.type:{voteable.type}'], + ) voteable._update_from_response(response) def unvote(self, voteable): @@ -87,14 +127,29 @@ def unvote(self, voteable): url = _url_for_vote_comment(voteable.id) else: raise utils.CommentClientRequestError("Can only vote / unvote for threads or comments") - params = {'user_id': self.id} - response = utils.perform_request( - 'delete', - url, - params, - metric_action='user.unvote', - metric_tags=self._metric_tags + [f'target.type:{voteable.type}'], - ) + course_key = utils.get_course_key(self.attributes.get("course_id")) + if is_forum_v2_enabled(course_key): + if voteable.type == 'thread': + response = forum_api.delete_thread_vote( + thread_id=voteable.id, + user_id=self.id, + course_id=str(course_key) + ) + else: + response = forum_api.delete_comment_vote( + comment_id=voteable.id, + user_id=self.id, + course_id=str(course_key) + ) + else: + params = {'user_id': self.id} + response = utils.perform_request( + 'delete', + url, + params, + metric_action='user.unvote', + metric_tags=self._metric_tags + [f'target.type:{voteable.type}'], + ) voteable._update_from_response(response) def active_threads(self, query_params=None): @@ -105,14 +160,28 @@ def active_threads(self, query_params=None): url = _url_for_user_active_threads(self.id) params = {'course_id': str(self.course_id)} params.update(query_params) - response = utils.perform_request( - 'get', - url, - params, - metric_action='user.active_threads', - metric_tags=self._metric_tags, - paged_results=True, - ) + course_key = utils.get_course_key(self.attributes.get("course_id")) + if is_forum_v2_enabled(course_key): + if user_id := params.get("user_id"): + params["user_id"] = str(user_id) + if page := params.get("page"): + params["page"] = int(page) + if per_page := params.get("per_page"): + params["per_page"] = int(per_page) + if count_flagged := params.get("count_flagged", False): + params["count_flagged"] = str_to_bool(count_flagged) + if not params.get("course_id"): + params["course_id"] = str(course_key) + response = forum_api.get_user_active_threads(**params) + else: + response = utils.perform_request( + 'get', + url, + params, + metric_action='user.active_threads', + metric_tags=self._metric_tags, + paged_results=True, + ) return response.get('collection', []), response.get('page', 1), response.get('num_pages', 1) def subscribed_threads(self, query_params=None): @@ -125,14 +194,28 @@ def subscribed_threads(self, query_params=None): url = _url_for_user_subscribed_threads(self.id) params = {'course_id': str(self.course_id)} params.update(query_params) - response = utils.perform_request( - 'get', - url, - params, - metric_action='user.subscribed_threads', - metric_tags=self._metric_tags, - paged_results=True - ) + course_key = utils.get_course_key(self.attributes.get("course_id")) + if is_forum_v2_enabled(course_key): + if user_id := params.get("user_id"): + params["user_id"] = str(user_id) + if page := params.get("page"): + params["page"] = int(page) + if per_page := params.get("per_page"): + params["per_page"] = int(per_page) + if count_flagged := params.get("count_flagged", False): + params["count_flagged"] = str_to_bool(count_flagged) + if not params.get("course_id"): + params["course_id"] = str(course_key) + response = forum_api.get_user_threads(**params) + else: + response = utils.perform_request( + 'get', + url, + params, + metric_action='user.subscribed_threads', + metric_tags=self._metric_tags, + paged_results=True + ) return utils.CommentClientPaginatedResult( collection=response.get('collection', []), page=response.get('page', 1), @@ -144,23 +227,39 @@ def _retrieve(self, *args, **kwargs): url = self.url(action='get', params=self.attributes) retrieve_params = self.default_retrieve_params.copy() retrieve_params.update(kwargs) + if self.attributes.get('course_id'): retrieve_params['course_id'] = str(self.course_id) if self.attributes.get('group_id'): retrieve_params['group_id'] = self.group_id - try: - response = utils.perform_request( - 'get', - url, - retrieve_params, - metric_action='model.retrieve', - metric_tags=self._metric_tags, - ) - except utils.CommentClientRequestError as e: - if e.status_code == 404: - # attempt to gracefully recover from a previous failure - # to sync this user to the comments service. - self.save() + + # course key -> id conversation + course_id = retrieve_params.get('course_id') + if course_id: + course_id = str(course_id) + retrieve_params['course_id'] = course_id + course_key = utils.get_course_key(course_id) + + if is_forum_v2_enabled(course_key): + group_ids = [retrieve_params['group_id']] if 'group_id' in retrieve_params else [] + is_complete = retrieve_params['complete'] + try: + response = forum_api.get_user( + self.attributes["id"], + group_ids=group_ids, + course_id=course_id, + complete=is_complete + ) + except ForumV2RequestError as e: + self.save({"course_id": course_id}) + response = forum_api.get_user( + self.attributes["id"], + group_ids=group_ids, + course_id=course_id, + complete=is_complete + ) + else: + try: response = utils.perform_request( 'get', url, @@ -168,33 +267,52 @@ def _retrieve(self, *args, **kwargs): metric_action='model.retrieve', metric_tags=self._metric_tags, ) - else: - raise + except utils.CommentClientRequestError as e: + if e.status_code == 404: + # attempt to gracefully recover from a previous failure + # to sync this user to the comments service. + self.save() + response = utils.perform_request( + 'get', + url, + retrieve_params, + metric_action='model.retrieve', + metric_tags=self._metric_tags, + ) + else: + raise self._update_from_response(response) def retire(self, retired_username): - url = _url_for_retire(self.id) - params = {'retired_username': retired_username} - - utils.perform_request( - 'post', - url, - params, - raw=True, - metric_action='user.retire', - metric_tags=self._metric_tags - ) + course_key = utils.get_course_key(self.attributes.get("course_id")) + if is_forum_v2_enabled(course_key): + forum_api.retire_user(user_id=self.id, retired_username=retired_username, course_id=str(course_key)) + else: + url = _url_for_retire(self.id) + params = {'retired_username': retired_username} + utils.perform_request( + 'post', + url, + params, + raw=True, + metric_action='user.retire', + metric_tags=self._metric_tags + ) def replace_username(self, new_username): - url = _url_for_username_replacement(self.id) - params = {"new_username": new_username} - - utils.perform_request( - 'post', - url, - params, - raw=True, - ) + course_key = utils.get_course_key(self.attributes.get("course_id")) + if is_forum_v2_enabled(course_key): + forum_api.update_username(user_id=self.id, new_username=new_username, course_id=str(course_key)) + else: + url = _url_for_username_replacement(self.id) + params = {"new_username": new_username} + + utils.perform_request( + 'post', + url, + params, + raw=True, + ) def _url_for_vote_comment(comment_id): diff --git a/openedx/core/djangoapps/django_comment_common/comment_client/utils.py b/openedx/core/djangoapps/django_comment_common/comment_client/utils.py index a67cdbdbc483..e77f39e6277d 100644 --- a/openedx/core/djangoapps/django_comment_common/comment_client/utils.py +++ b/openedx/core/djangoapps/django_comment_common/comment_client/utils.py @@ -7,6 +7,7 @@ import requests from django.utils.translation import get_language +from opaque_keys.edx.keys import CourseKey from .settings import SERVICE_HOST as COMMENTS_SERVICE @@ -167,3 +168,19 @@ def check_forum_heartbeat(): return 'forum', False, res.get('check', 'Forum heartbeat failed') except Exception as fail: return 'forum', False, str(fail) + + +def get_course_key(course_id: CourseKey | str | None) -> CourseKey | None: + """ + Returns a CourseKey if the provided course_id is a valid string representation of a CourseKey. + If course_id is None or already a CourseKey object, it returns the course_id as is. + Args: + course_id (CourseKey | str | None): The course ID to be converted. + Returns: + CourseKey | None: The corresponding CourseKey object or None if the input is None. + Raises: + KeyError: If course_id is not a valid string representation of a CourseKey. + """ + if course_id and isinstance(course_id, str): + course_id = CourseKey.from_string(course_id) + return course_id diff --git a/openedx/core/djangoapps/user_api/accounts/utils.py b/openedx/core/djangoapps/user_api/accounts/utils.py index 3cc03c02ed5d..826dbd42cd13 100644 --- a/openedx/core/djangoapps/user_api/accounts/utils.py +++ b/openedx/core/djangoapps/user_api/accounts/utils.py @@ -51,11 +51,11 @@ def format_social_link(platform_name, new_social_link): """ Given a user's social link, returns a safe absolute url for the social link. - Returns the following based on the provided new_social_link: - 1) Given an empty string, returns '' - 1) Given a valid username, return 'https://www.[platform_name_base][username]' - 2) Given a valid URL, return 'https://www.[platform_name_base][username]' - 3) Given anything unparseable, returns None + Returns: + - An empty string if `new_social_link` is empty. + - A formatted URL if `new_social_link` is a username. + - Returns `new_social_link` if it is a valid URL. + - None for unparseable inputs. """ # Blank social links should return '' or None as was passed in. if not new_social_link: diff --git a/openedx/core/djangoapps/xblock/api.py b/openedx/core/djangoapps/xblock/api.py index 00bb8bc356a6..35c05cd7b407 100644 --- a/openedx/core/djangoapps/xblock/api.py +++ b/openedx/core/djangoapps/xblock/api.py @@ -33,7 +33,12 @@ LearningCoreXBlockRuntime, ) from .data import CheckPerm, LatestVersion -from .utils import get_secure_token_for_xblock_handler, get_xblock_id_for_anonymous_user +from .rest_api.url_converters import VersionConverter +from .utils import ( + get_secure_token_for_xblock_handler, + get_xblock_id_for_anonymous_user, + get_auto_latest_version, +) from .runtime.learning_core_runtime import LearningCoreXBlockRuntime @@ -208,13 +213,26 @@ def get_component_from_usage_key(usage_key: UsageKeyV2) -> Component: ) -def get_block_draft_olx(usage_key: UsageKeyV2) -> str: +def get_block_olx( + usage_key: UsageKeyV2, + *, + version: int | LatestVersion = LatestVersion.AUTO +) -> str: """ - Get the OLX source of the draft version of the given Learning-Core-backed XBlock. + Get the OLX source of the of the given Learning-Core-backed XBlock and a version. """ - # Inefficient but simple approach. Optimize later if needed. component = get_component_from_usage_key(usage_key) - component_version = component.versioning.draft + version = get_auto_latest_version(version) + + if version == LatestVersion.DRAFT: + component_version = component.versioning.draft + elif version == LatestVersion.PUBLISHED: + component_version = component.versioning.published + else: + assert isinstance(version, int) + component_version = component.versioning.version_num(version) + if component_version is None: + raise NoSuchUsage(usage_key) # TODO: we should probably make a method on ComponentVersion that returns # a content based on the name. Accessing by componentversioncontent__key is @@ -224,6 +242,11 @@ def get_block_draft_olx(usage_key: UsageKeyV2) -> str: return content.text +def get_block_draft_olx(usage_key: UsageKeyV2) -> str: + """ DEPRECATED. Use get_block_olx(). Can be removed post-Teak. """ + return get_block_olx(usage_key, version=LatestVersion.DRAFT) + + def render_block_view(block, view_name, user): # pylint: disable=unused-argument """ Get the HTML, JS, and CSS needed to render the given XBlock view. diff --git a/openedx/core/djangoapps/xblock/rest_api/serializers.py b/openedx/core/djangoapps/xblock/rest_api/serializers.py new file mode 100644 index 000000000000..bb4dd1da229f --- /dev/null +++ b/openedx/core/djangoapps/xblock/rest_api/serializers.py @@ -0,0 +1,11 @@ +""" +Serializers for the xblock REST API +""" +from rest_framework import serializers + + +class XBlockOlxSerializer(serializers.Serializer): + """ + Serializer for representing an XBlock's OLX + """ + olx = serializers.CharField() diff --git a/openedx/core/djangoapps/xblock/rest_api/urls.py b/openedx/core/djangoapps/xblock/rest_api/urls.py index ee41b43f5b39..a83d5104e570 100644 --- a/openedx/core/djangoapps/xblock/rest_api/urls.py +++ b/openedx/core/djangoapps/xblock/rest_api/urls.py @@ -19,6 +19,8 @@ path('', views.block_metadata), # get/post full json fields of an XBlock: path('fields/', views.BlockFieldsView.as_view()), + # Get the OLX source code of the specified block + path('olx/', views.get_block_olx_view), # render one of this XBlock's views (e.g. student_view) path('view//', views.render_block_view), # get the URL needed to call this XBlock's handlers diff --git a/openedx/core/djangoapps/xblock/rest_api/views.py b/openedx/core/djangoapps/xblock/rest_api/views.py index d69edcbfd51c..edcbf22e0d3d 100644 --- a/openedx/core/djangoapps/xblock/rest_api/views.py +++ b/openedx/core/djangoapps/xblock/rest_api/views.py @@ -33,9 +33,11 @@ get_handler_url as _get_handler_url, load_block, render_block_view as _render_block_view, + get_block_olx, ) from ..utils import validate_secure_token_for_xblock_handler from .url_converters import VersionConverter +from .serializers import XBlockOlxSerializer User = get_user_model() @@ -213,6 +215,23 @@ def xblock_handler( return response +@api_view(['GET']) +@view_auth_classes(is_authenticated=False) +def get_block_olx_view( + request, + usage_key: UsageKeyV2, + version: LatestVersion | int = LatestVersion.AUTO, +): + """ + Get the OLX (XML serialization) of the specified XBlock + """ + context_impl = get_learning_context_impl(usage_key) + if not context_impl.can_view_block_for_editing(request.user, usage_key): + raise PermissionDenied(f"You don't have permission to access the OLX of component '{usage_key}'.") + olx = get_block_olx(usage_key, version=version) + return Response(XBlockOlxSerializer({"olx": olx}).data) + + def cors_allow_xblock_handler(sender, request, **kwargs): # lint-amnesty, pylint: disable=unused-argument """ Sandboxed XBlocks need to be able to call XBlock handlers via POST, diff --git a/openedx/core/djangoapps/xblock/runtime/learning_core_runtime.py b/openedx/core/djangoapps/xblock/runtime/learning_core_runtime.py index fd2e867a3a8f..dde2084e54cd 100644 --- a/openedx/core/djangoapps/xblock/runtime/learning_core_runtime.py +++ b/openedx/core/djangoapps/xblock/runtime/learning_core_runtime.py @@ -24,6 +24,7 @@ from openedx.core.lib.xblock_serializer.api import serialize_modulestore_block_for_learning_core from openedx.core.lib.xblock_serializer.data import StaticFile from ..data import AuthoredDataMode, LatestVersion +from ..utils import get_auto_latest_version from ..learning_context.manager import get_learning_context_impl from .runtime import XBlockRuntime @@ -178,11 +179,7 @@ def get_block(self, usage_key, for_parent=None, *, version: int | LatestVersion # just get it the easy way. component = self._get_component_from_usage_key(usage_key) - if version == LatestVersion.AUTO: - if self.authored_data_mode == AuthoredDataMode.DEFAULT_DRAFT: - version = LatestVersion.DRAFT - else: - version = LatestVersion.PUBLISHED + version = get_auto_latest_version(version) if self.authored_data_mode == AuthoredDataMode.STRICTLY_PUBLISHED and version != LatestVersion.PUBLISHED: raise ValidationError("This runtime only allows accessing the published version of components") if version == LatestVersion.DRAFT: diff --git a/openedx/core/djangoapps/xblock/utils.py b/openedx/core/djangoapps/xblock/utils.py index 375bb9d21450..b4ae054cf498 100644 --- a/openedx/core/djangoapps/xblock/utils.py +++ b/openedx/core/djangoapps/xblock/utils.py @@ -11,6 +11,10 @@ import crum from django.conf import settings +from openedx.core.djangoapps.xblock.apps import get_xblock_app_config + +from .data import AuthoredDataMode, LatestVersion + def get_secure_token_for_xblock_handler(user_id, block_key_str, time_idx=0): """ @@ -167,3 +171,18 @@ def get_xblock_id_for_anonymous_user(user): return current_request.session["xblock_id_for_anonymous_user"] else: raise RuntimeError("Cannot get a user ID for an anonymous user outside of an HTTP request context.") + + +def get_auto_latest_version(version: int | LatestVersion) -> int | LatestVersion: + """ + Gets the actual LatestVersion if is `LatestVersion.AUTO`; + otherwise, returns the same value. + """ + if version == LatestVersion.AUTO: + authored_data_mode = get_xblock_app_config().get_runtime_params()["authored_data_mode"] + version = ( + LatestVersion.DRAFT + if authored_data_mode == AuthoredDataMode.DEFAULT_DRAFT + else LatestVersion.PUBLISHED + ) + return version diff --git a/openedx/core/release.py b/openedx/core/release.py index ce30df8cc543..ac064f3d36ab 100644 --- a/openedx/core/release.py +++ b/openedx/core/release.py @@ -8,7 +8,7 @@ # The release line: an Open edX release name ("ficus"), or "master". # This should always be "master" on the master branch, and will be changed # manually when we start release-line branches, like open-release/ficus.master. -RELEASE_LINE = "master" +RELEASE_LINE = "sumac" def doc_version(): diff --git a/requirements/common_constraints.txt b/requirements/common_constraints.txt index f2ef0216ca07..b8166ba67540 100644 --- a/requirements/common_constraints.txt +++ b/requirements/common_constraints.txt @@ -15,7 +15,6 @@ # Note: Changes to this file will automatically be used by other repos, referencing # this file from Github directly. It does not require packaging in edx-lint. - # using LTS django version Django<5.0 @@ -26,9 +25,6 @@ elasticsearch<7.14.0 # django-simple-history>3.0.0 adds indexing and causes a lot of migrations to be affected -# Cause: https://github.com/openedx/event-tracking/pull/290 -# event-tracking 2.4.1 upgrades to pymongo 4.4.0 which is not supported on edx-platform. -# We will pin event-tracking to do not break existing installations -# This can be unpinned once https://github.com/openedx/edx-platform/issues/34586 -# has been resolved and edx-platform is running with pymongo>=4.4.0 - +# Cause: https://github.com/openedx/edx-lint/issues/458 +# This can be unpinned once https://github.com/openedx/edx-lint/issues/459 has been resolved. +pip<24.3 diff --git a/requirements/constraints.txt b/requirements/constraints.txt index 312b6b78657a..969c20e114de 100644 --- a/requirements/constraints.txt +++ b/requirements/constraints.txt @@ -146,7 +146,7 @@ optimizely-sdk<5.0 # Date: 2023-09-18 # pinning this version to avoid updates while the library is being developed # Issue for unpinning: https://github.com/openedx/edx-platform/issues/35269 -openedx-learning==0.16.1 +openedx-learning==0.18.1 # Date: 2023-11-29 # Open AI version 1.0.0 dropped support for openai.ChatCompletion which is currently in use in enterprise. @@ -167,6 +167,7 @@ pycodestyle<2.9.0 # Date: 2021-07-12 # Issue for unpinning: https://github.com/openedx/edx-platform/issues/33560 pylint<2.16.0 # greater version failing quality test. Fix them in seperate ticket. +astroid<2.14.0 # Date: 2021-08-25 # At the time of writing this comment, we do not know whether py2neo>=2022 diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 166a9dc01bc8..3767b6811acf 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -57,7 +57,9 @@ backoff==1.10.0 bcrypt==4.2.0 # via paramiko beautifulsoup4==4.12.3 - # via pynliner + # via + # openedx-forum + # pynliner billiard==4.2.1 # via celery bleach[css]==6.1.0 @@ -168,7 +170,7 @@ defusedxml==0.7.1 # ora2 # python3-openid # social-auth-core -django==4.2.16 +django==4.2.17 # via # -c requirements/edx/../common_constraints.txt # -c requirements/edx/../constraints.txt @@ -234,6 +236,7 @@ django==4.2.16 # openedx-django-wiki # openedx-events # openedx-filters + # openedx-forum # openedx-learning # ora2 # social-auth-app-django @@ -382,6 +385,7 @@ djangorestframework==3.14.0 # edx-organizations # edx-proctoring # edx-submissions + # openedx-forum # openedx-learning # ora2 # super-csv @@ -513,8 +517,10 @@ edx-rest-api-client==6.0.0 # -r requirements/edx/kernel.in # edx-enterprise # edx-proctoring -edx-search==4.1.0 - # via -r requirements/edx/kernel.in +edx-search==4.1.1 + # via + # -r requirements/edx/kernel.in + # openedx-forum edx-sga==0.25.0 # via -r requirements/edx/bundled.in edx-submissions==3.8.2 @@ -547,6 +553,7 @@ elasticsearch==7.9.1 # via # -c requirements/edx/../common_constraints.txt # edx-search + # openedx-forum enmerkar==0.7.1 # via enmerkar-underscore enmerkar-underscore==2.3.1 @@ -770,7 +777,9 @@ multidict==6.1.0 # aiohttp # yarl mysqlclient==2.2.5 - # via -r requirements/edx/kernel.in + # via + # -r requirements/edx/kernel.in + # openedx-forum newrelic==10.2.0 # via edx-django-utils nh3==0.2.18 @@ -800,7 +809,9 @@ openai==0.28.1 # -c requirements/edx/../constraints.txt # edx-enterprise openedx-atlas==0.6.2 - # via -r requirements/edx/kernel.in + # via + # -r requirements/edx/kernel.in + # openedx-forum openedx-calc==3.1.2 # via -r requirements/edx/kernel.in openedx-django-pyfs==3.7.0 @@ -825,7 +836,9 @@ openedx-filters==1.11.0 # -r requirements/edx/kernel.in # lti-consumer-xblock # ora2 -openedx-learning==0.16.1 +openedx-forum==0.1.4 + # via -r requirements/edx/kernel.in +openedx-learning==0.18.1 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in @@ -958,6 +971,7 @@ pymongo==4.4.0 # edx-opaque-keys # event-tracking # mongoengine + # openedx-forum # openedx-mongodbproxy pynacl==1.5.0 # via @@ -1067,6 +1081,7 @@ requests==2.32.3 # mailsnake # meilisearch # openai + # openedx-forum # optimizely-sdk # pyjwkest # pylti1p3 diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 3283d27e2179..7a1fe5de5104 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -83,9 +83,12 @@ asn1crypto==1.5.1 # snowflake-connector-python astroid==2.13.5 # via + # -c requirements/edx/../constraints.txt + # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # pylint # pylint-celery + # sphinx-autoapi attrs==24.2.0 # via # -r requirements/edx/doc.txt @@ -119,6 +122,7 @@ beautifulsoup4==4.12.3 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt + # openedx-forum # pydata-sphinx-theme # pynliner billiard==4.2.1 @@ -333,7 +337,7 @@ distlib==0.3.9 # via # -r requirements/edx/testing.txt # virtualenv -django==4.2.16 +django==4.2.17 # via # -c requirements/edx/../common_constraints.txt # -c requirements/edx/../constraints.txt @@ -403,6 +407,7 @@ django==4.2.16 # openedx-django-wiki # openedx-events # openedx-filters + # openedx-forum # openedx-learning # ora2 # social-auth-app-django @@ -615,6 +620,7 @@ djangorestframework==3.14.0 # edx-organizations # edx-proctoring # edx-submissions + # openedx-forum # openedx-learning # ora2 # super-csv @@ -806,10 +812,11 @@ edx-rest-api-client==6.0.0 # -r requirements/edx/testing.txt # edx-enterprise # edx-proctoring -edx-search==4.1.0 +edx-search==4.1.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt + # openedx-forum edx-sga==0.25.0 # via # -r requirements/edx/doc.txt @@ -856,6 +863,7 @@ elasticsearch==7.9.1 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # edx-search + # openedx-forum enmerkar==0.7.1 # via # -r requirements/edx/doc.txt @@ -1103,6 +1111,7 @@ jinja2==3.1.4 # code-annotations # diff-cover # sphinx + # sphinx-autoapi jmespath==1.0.1 # via # -r requirements/edx/doc.txt @@ -1166,6 +1175,7 @@ lazy==1.6 # xblock lazy-object-proxy==1.10.0 # via + # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # astroid libsass==0.10.0 @@ -1292,6 +1302,7 @@ mysqlclient==2.2.5 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt + # openedx-forum newrelic==10.2.0 # via # -r requirements/edx/doc.txt @@ -1342,6 +1353,7 @@ openedx-atlas==0.6.2 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt + # openedx-forum openedx-calc==3.1.2 # via # -r requirements/edx/doc.txt @@ -1376,7 +1388,11 @@ openedx-filters==1.11.0 # -r requirements/edx/testing.txt # lti-consumer-xblock # ora2 -openedx-learning==0.16.1 +openedx-forum==0.1.4 + # via + # -r requirements/edx/doc.txt + # -r requirements/edx/testing.txt +openedx-learning==0.18.1 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/doc.txt @@ -1644,6 +1660,7 @@ pymongo==4.4.0 # edx-opaque-keys # event-tracking # mongoengine + # openedx-forum # openedx-mongodbproxy pynacl==1.5.0 # via @@ -1794,6 +1811,7 @@ pyyaml==6.0.2 # edx-django-release-util # edx-i18n-tools # jsondiff + # sphinx-autoapi # sphinxcontrib-openapi # xblock random2==1.0.2 @@ -1839,6 +1857,7 @@ requests==2.32.3 # mailsnake # meilisearch # openai + # openedx-forum # optimizely-sdk # pact-python # pyjwkest @@ -1994,6 +2013,7 @@ sphinx==8.1.3 # via # -r requirements/edx/doc.txt # pydata-sphinx-theme + # sphinx-autoapi # sphinx-book-theme # sphinx-design # sphinx-mdinclude @@ -2001,6 +2021,8 @@ sphinx==8.1.3 # sphinxcontrib-httpdomain # sphinxcontrib-openapi # sphinxext-rediraffe +sphinx-autoapi==3.4.0 + # via -r requirements/edx/doc.txt sphinx-book-theme==1.1.3 # via -r requirements/edx/doc.txt sphinx-design==0.6.1 diff --git a/requirements/edx/doc.in b/requirements/edx/doc.in index 013bafc42ec0..78afbf9d6bd0 100644 --- a/requirements/edx/doc.in +++ b/requirements/edx/doc.in @@ -10,3 +10,4 @@ sphinx-design # provides various responsive web-components sphinxcontrib-openapi[markdown] # Be able to render openapi schema in a sphinx project sphinxext-rediraffe # Quickly and easily redirect when we move pages around. sphinx-reredirects # Redirect from a sphinx project out to other places on the web including other sphinx projects +sphinx-autoapi diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index 338ab221d480..89ba44565989 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -57,6 +57,10 @@ asn1crypto==1.5.1 # via # -r requirements/edx/base.txt # snowflake-connector-python +astroid==2.13.5 + # via + # -c requirements/edx/../constraints.txt + # sphinx-autoapi attrs==24.2.0 # via # -r requirements/edx/base.txt @@ -85,6 +89,7 @@ bcrypt==4.2.0 beautifulsoup4==4.12.3 # via # -r requirements/edx/base.txt + # openedx-forum # pydata-sphinx-theme # pynliner billiard==4.2.1 @@ -222,7 +227,7 @@ defusedxml==0.7.1 # ora2 # python3-openid # social-auth-core -django==4.2.16 +django==4.2.17 # via # -c requirements/edx/../common_constraints.txt # -c requirements/edx/../constraints.txt @@ -288,6 +293,7 @@ django==4.2.16 # openedx-django-wiki # openedx-events # openedx-filters + # openedx-forum # openedx-learning # ora2 # social-auth-app-django @@ -452,6 +458,7 @@ djangorestframework==3.14.0 # edx-organizations # edx-proctoring # edx-submissions + # openedx-forum # openedx-learning # ora2 # super-csv @@ -594,8 +601,10 @@ edx-rest-api-client==6.0.0 # -r requirements/edx/base.txt # edx-enterprise # edx-proctoring -edx-search==4.1.0 - # via -r requirements/edx/base.txt +edx-search==4.1.1 + # via + # -r requirements/edx/base.txt + # openedx-forum edx-sga==0.25.0 # via -r requirements/edx/base.txt edx-submissions==3.8.2 @@ -631,6 +640,7 @@ elasticsearch==7.9.1 # -c requirements/edx/../common_constraints.txt # -r requirements/edx/base.txt # edx-search + # openedx-forum enmerkar==0.7.1 # via # -r requirements/edx/base.txt @@ -790,6 +800,7 @@ jinja2==3.1.4 # -r requirements/edx/base.txt # code-annotations # sphinx + # sphinx-autoapi jmespath==1.0.1 # via # -r requirements/edx/base.txt @@ -840,6 +851,8 @@ lazy==1.6 # lti-consumer-xblock # ora2 # xblock +lazy-object-proxy==1.10.0 + # via astroid libsass==0.10.0 # via # -c requirements/edx/../constraints.txt @@ -924,7 +937,9 @@ multidict==6.1.0 # aiohttp # yarl mysqlclient==2.2.5 - # via -r requirements/edx/base.txt + # via + # -r requirements/edx/base.txt + # openedx-forum newrelic==10.2.0 # via # -r requirements/edx/base.txt @@ -960,7 +975,9 @@ openai==0.28.1 # -r requirements/edx/base.txt # edx-enterprise openedx-atlas==0.6.2 - # via -r requirements/edx/base.txt + # via + # -r requirements/edx/base.txt + # openedx-forum openedx-calc==3.1.2 # via -r requirements/edx/base.txt openedx-django-pyfs==3.7.0 @@ -986,7 +1003,9 @@ openedx-filters==1.11.0 # -r requirements/edx/base.txt # lti-consumer-xblock # ora2 -openedx-learning==0.16.1 +openedx-forum==0.1.4 + # via -r requirements/edx/base.txt +openedx-learning==0.18.1 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt @@ -1155,6 +1174,7 @@ pymongo==4.4.0 # edx-opaque-keys # event-tracking # mongoengine + # openedx-forum # openedx-mongodbproxy pynacl==1.5.0 # via @@ -1243,6 +1263,7 @@ pyyaml==6.0.2 # edx-django-release-util # edx-i18n-tools # jsondiff + # sphinx-autoapi # sphinxcontrib-openapi # xblock random2==1.0.2 @@ -1279,6 +1300,7 @@ requests==2.32.3 # mailsnake # meilisearch # openai + # openedx-forum # optimizely-sdk # pyjwkest # pylti1p3 @@ -1401,6 +1423,7 @@ sphinx==8.1.3 # via # -r requirements/edx/doc.in # pydata-sphinx-theme + # sphinx-autoapi # sphinx-book-theme # sphinx-design # sphinx-mdinclude @@ -1408,6 +1431,8 @@ sphinx==8.1.3 # sphinxcontrib-httpdomain # sphinxcontrib-openapi # sphinxext-rediraffe +sphinx-autoapi==3.4.0 + # via -r requirements/edx/doc.in sphinx-book-theme==1.1.3 # via -r requirements/edx/doc.in sphinx-design==0.6.1 @@ -1552,7 +1577,9 @@ webob==1.8.8 # -r requirements/edx/base.txt # xblock wrapt==1.16.0 - # via -r requirements/edx/base.txt + # via + # -r requirements/edx/base.txt + # astroid xblock[django]==5.1.0 # via # -r requirements/edx/base.txt diff --git a/requirements/edx/kernel.in b/requirements/edx/kernel.in index a5b510742ac7..575c621a85d7 100644 --- a/requirements/edx/kernel.in +++ b/requirements/edx/kernel.in @@ -119,6 +119,7 @@ openedx-calc # Library supporting mathematical calculatio openedx-django-require openedx-events # Open edX Events from Hooks Extension Framework (OEP-50) openedx-filters # Open edX Filters from Hooks Extension Framework (OEP-50) +openedx-forum # Open edX forum v2 application openedx-learning # Open edX Learning core (experimental) openedx-mongodbproxy openedx-django-wiki diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index 14d7a977d865..cbb57628e5e5 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -57,6 +57,7 @@ asn1crypto==1.5.1 # snowflake-connector-python astroid==2.13.5 # via + # -c requirements/edx/../constraints.txt # pylint # pylint-celery attrs==24.2.0 @@ -86,6 +87,7 @@ beautifulsoup4==4.12.3 # via # -r requirements/edx/base.txt # -r requirements/edx/testing.in + # openedx-forum # pynliner billiard==4.2.1 # via @@ -251,7 +253,7 @@ dill==0.3.9 # via pylint distlib==0.3.9 # via virtualenv -django==4.2.16 +django==4.2.17 # via # -c requirements/edx/../common_constraints.txt # -c requirements/edx/../constraints.txt @@ -317,6 +319,7 @@ django==4.2.16 # openedx-django-wiki # openedx-events # openedx-filters + # openedx-forum # openedx-learning # ora2 # social-auth-app-django @@ -481,6 +484,7 @@ djangorestframework==3.14.0 # edx-organizations # edx-proctoring # edx-submissions + # openedx-forum # openedx-learning # ora2 # super-csv @@ -620,8 +624,10 @@ edx-rest-api-client==6.0.0 # -r requirements/edx/base.txt # edx-enterprise # edx-proctoring -edx-search==4.1.0 - # via -r requirements/edx/base.txt +edx-search==4.1.1 + # via + # -r requirements/edx/base.txt + # openedx-forum edx-sga==0.25.0 # via -r requirements/edx/base.txt edx-submissions==3.8.2 @@ -657,6 +663,7 @@ elasticsearch==7.9.1 # -c requirements/edx/../common_constraints.txt # -r requirements/edx/base.txt # edx-search + # openedx-forum enmerkar==0.7.1 # via # -r requirements/edx/base.txt @@ -975,7 +982,9 @@ multidict==6.1.0 # aiohttp # yarl mysqlclient==2.2.5 - # via -r requirements/edx/base.txt + # via + # -r requirements/edx/base.txt + # openedx-forum newrelic==10.2.0 # via # -r requirements/edx/base.txt @@ -1011,7 +1020,9 @@ openai==0.28.1 # -r requirements/edx/base.txt # edx-enterprise openedx-atlas==0.6.2 - # via -r requirements/edx/base.txt + # via + # -r requirements/edx/base.txt + # openedx-forum openedx-calc==3.1.2 # via -r requirements/edx/base.txt openedx-django-pyfs==3.7.0 @@ -1037,7 +1048,9 @@ openedx-filters==1.11.0 # -r requirements/edx/base.txt # lti-consumer-xblock # ora2 -openedx-learning==0.16.1 +openedx-forum==0.1.4 + # via -r requirements/edx/base.txt +openedx-learning==0.18.1 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt @@ -1241,6 +1254,7 @@ pymongo==4.4.0 # edx-opaque-keys # event-tracking # mongoengine + # openedx-forum # openedx-mongodbproxy pynacl==1.5.0 # via @@ -1397,6 +1411,7 @@ requests==2.32.3 # mailsnake # meilisearch # openai + # openedx-forum # optimizely-sdk # pact-python # pyjwkest diff --git a/requirements/pip.txt b/requirements/pip.txt index 346a0611f0c5..797974efa45a 100644 --- a/requirements/pip.txt +++ b/requirements/pip.txt @@ -9,6 +9,8 @@ wheel==0.44.0 # The following packages are considered to be unsafe in a requirements file: pip==24.2 - # via -r requirements/pip.in + # via + # -c requirements/common_constraints.txt + # -r requirements/pip.in setuptools==75.2.0 # via -r requirements/pip.in diff --git a/scripts/user_retirement/requirements/base.txt b/scripts/user_retirement/requirements/base.txt index 5c2c300341a8..c273dee82b98 100644 --- a/scripts/user_retirement/requirements/base.txt +++ b/scripts/user_retirement/requirements/base.txt @@ -35,7 +35,7 @@ click==8.1.6 # edx-django-utils cryptography==43.0.3 # via pyjwt -django==4.2.16 +django==4.2.17 # via # -c scripts/user_retirement/requirements/../../../requirements/common_constraints.txt # -c scripts/user_retirement/requirements/../../../requirements/constraints.txt diff --git a/scripts/user_retirement/requirements/testing.txt b/scripts/user_retirement/requirements/testing.txt index a149a1d10cd1..bf54f6d19506 100644 --- a/scripts/user_retirement/requirements/testing.txt +++ b/scripts/user_retirement/requirements/testing.txt @@ -52,7 +52,7 @@ cryptography==43.0.3 # pyjwt ddt==1.7.2 # via -r scripts/user_retirement/requirements/testing.in -django==4.2.16 +django==4.2.17 # via # -r scripts/user_retirement/requirements/base.txt # django-crum diff --git a/xmodule/assets/html/_display.scss b/xmodule/assets/html/_display.scss index 25e2ce4fbd64..6206d7ec4b01 100644 --- a/xmodule/assets/html/_display.scss +++ b/xmodule/assets/html/_display.scss @@ -118,6 +118,7 @@ a { img { max-width: 100%; + height: auto; } pre {