diff --git a/.github/workflows/docker-compose.yml.mysqldbdump b/.github/workflows/docker-compose.yml.mysqldbdump index 0853d250ff..6affa8c788 100644 --- a/.github/workflows/docker-compose.yml.mysqldbdump +++ b/.github/workflows/docker-compose.yml.mysqldbdump @@ -16,7 +16,7 @@ services: retries: 10 edxapp: image: edxops/edxapp:latest - command: bash -c 'source /edx/app/edxapp/edxapp_env && cd /edx/app/edxapp/edx-platform/ && paver update_db' + command: bash -c 'source /edx/app/edxapp/edxapp_env && cd /edx/app/edxapp/edx-platform/ && make migrate' volumes: - ../../:/edx/app/edxapp/edx-platform depends_on: diff --git a/.github/workflows/docs-build-check.yml b/.github/workflows/docs-build-check.yml deleted file mode 100644 index d298b85101..0000000000 --- a/.github/workflows/docs-build-check.yml +++ /dev/null @@ -1,50 +0,0 @@ -name: Docs build - -on: - pull_request: - push: - branches: - - master - -jobs: - tests: - name: Docs build - runs-on: ${{ matrix.os }} - strategy: - matrix: - python-version: ['3.8'] - os: ['ubuntu-20.04'] - - steps: - - uses: actions/checkout@v2 - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - - - name: Install system requirements - run: sudo apt update && sudo apt install -y libxmlsec1-dev - - - name: Install pip - run: python -m pip install -r requirements/pip.txt - - - name: Get pip cache dir - id: pip-cache-dir - run: echo "::set-output name=dir::$(pip cache dir)" - - - name: Cache pip dependencies - id: cache-dependencies - uses: actions/cache@v3 - with: - path: ${{ steps.pip-cache-dir.outputs.dir }} - key: ${{ runner.os }}-pip-${{ hashFiles('requirements/edx/development.txt') }} - restore-keys: ${{ runner.os }}-pip- - - - name: Install python dependencies - run: make dev-requirements - - - name: Install docs requirements - run: pip install -r requirements/edx/doc.txt - - - name: Docs build - run: make docs diff --git a/.github/workflows/upgrade-one-python-dependency.yml b/.github/workflows/upgrade-one-python-dependency.yml index 1802fa75ac..2ac704e999 100644 --- a/.github/workflows/upgrade-one-python-dependency.yml +++ b/.github/workflows/upgrade-one-python-dependency.yml @@ -12,6 +12,10 @@ on: description: 'Name of package to upgrade' required: true type: string + version: + description: 'Version number to upgrade to in constraints.txt (only needed if pinned)' + default: '' + type: string change_desc: description: | Description of change, for commit message and PR. (What does the new version add or fix?) @@ -37,6 +41,13 @@ jobs: with: python-version: "3.8" + - name: Update any pinned dependencies + env: + NEW_VERSION: "${{ inputs.version }}" + PACKAGE: "${{ inputs.package }}" + run: | + sed 's/^\('$PACKAGE'[^#]*\)==[^ #]\+/\1=='$NEW_VERSION'/' -i requirements/constraints.txt + - name: Run make upgrade-package env: PACKAGE: "${{ inputs.package }}" @@ -84,6 +95,7 @@ jobs: body: | ${{ env.body_prefix }}PR generated by workflow `${{ github.workflow_ref }}` on behalf of @${{ github.triggering_actor }}. assignees: "${{ github.triggering_actor }}" + reviewers: "${{ github.triggering_actor }}" - name: Job summary env: diff --git a/Dockerfile b/Dockerfile index 6948fefcf8..e77dd47473 100644 --- a/Dockerfile +++ b/Dockerfile @@ -57,6 +57,7 @@ RUN apt-get update && \ libpython3.8 \ libpython3.8-stdlib \ libmysqlclient21 \ + pkg-config \ libssl1.1 \ libxmlsec1-openssl \ # lynx: Required by https://github.com/openedx/edx-platform/blob/b489a4ecb122/openedx/core/lib/html_to_text.py#L16 diff --git a/Makefile b/Makefile index e075f092f1..649b1f77fc 100644 --- a/Makefile +++ b/Makefile @@ -2,8 +2,8 @@ .PHONY: base-requirements check-types clean \ compile-requirements detect_changed_source_translations dev-requirements \ docker_auth docker_build docker_push docker_tag docs extract_translations \ - guides help lint-imports local-requirements pre-requirements pull \ - pull_translations push_translations requirements shell swagger \ + guides help lint-imports local-requirements migrate migrate-lms migrate-cms \ + pre-requirements pull pull_translations push_translations requirements shell swagger \ technical-docs test-requirements ubuntu-requirements upgrade-package upgrade # Careful with mktemp syntax: it has to work on Mac and Ubuntu, which have differences. @@ -170,6 +170,16 @@ docker_push: docker_tag docker_auth ## push to docker hub lint-imports: lint-imports +migrate-lms: + python manage.py lms showmigrations --database default --traceback --pythonpath=. + python manage.py lms migrate --database default --traceback --pythonpath=. + +migrate-cms: + python manage.py cms showmigrations --database default --traceback --pythonpath=. + python manage.py cms migrate --database default --noinput --traceback --pythonpath=. + +migrate: migrate-lms migrate-cms + # WARNING (EXPERIMENTAL): # This installs the Ubuntu requirements necessary to make `pip install` and some other basic # dev commands to pass. This is not necessarily everything needed to get a working edx-platform. diff --git a/cms/djangoapps/contentstore/api/urls.py b/cms/djangoapps/contentstore/api/urls.py index 2e5fb009ed..e638a59f84 100644 --- a/cms/djangoapps/contentstore/api/urls.py +++ b/cms/djangoapps/contentstore/api/urls.py @@ -8,7 +8,6 @@ from cms.djangoapps.contentstore.api.views import course_import, course_quality, app_name = 'contentstore' -helper = "{0,1}" urlpatterns = [ re_path(fr'^v0/import/{settings.COURSE_ID_PATTERN}/$', diff --git a/cms/djangoapps/contentstore/management/commands/copy_libraries_from_v1_to_v2.py b/cms/djangoapps/contentstore/management/commands/copy_libraries_from_v1_to_v2.py new file mode 100644 index 0000000000..c0a8661151 --- /dev/null +++ b/cms/djangoapps/contentstore/management/commands/copy_libraries_from_v1_to_v2.py @@ -0,0 +1,125 @@ +"""A Command to Copy or uncopy V1 Content Libraries entires to be stored as v2 content libraries.""" + +import logging +from textwrap import dedent + +from django.core.management import BaseCommand, CommandError + +from opaque_keys.edx.keys import CourseKey +from opaque_keys.edx.locator import LibraryLocator + +from xmodule.modulestore.django import modulestore + + +from celery import group + +from cms.djangoapps.contentstore.tasks import create_v2_library_from_v1_library, delete_v2_library_from_v1_library + +from .prompt import query_yes_no + +log = logging.getLogger(__name__) + + +class Command(BaseCommand): + """ + Copy or uncopy V1 Content Libraries (default all) entires to be stored as v2 content libraries. + First Specify the uuid for the collection to store the content libraries in. + Specfiy --all for all libraries, library ids for specific libraries, + and -- file followed by the path for a list of libraries from a file. + + Example usage: + + $ ./manage.py cms copy_libraries_from_v1_to_v2 'collection_uuid' --all + $ ./manage.py cms copy_libraries_from_v1_to_v2 + library-v1:edX+DemoX+Demo_Library' 'library-v1:edX+DemoX+Better_Library' -c 'collection_uuid' + $ ./manage.py cms copy_libraries_from_v1_to_v2 --all --uncopy + $ ./manage.py cms copy_libraries_from_v1_to_v2 'library-v1:edX+DemoX+Better_Library' --uncopy + $ ./manage.py cms copy_libraries_from_v1_to_v2 + '11111111-2111-4111-8111-111111111111' + './list_of--library-locators- --file + + Note: + This Command Also produces an "output file" which contains the mapping of locators and the status of the copy. + """ + + help = dedent(__doc__) + CONFIRMATION_PROMPT = "Reindexing all libraries might be a time consuming operation. Do you want to continue?" + + def add_arguments(self, parser): + """arguements for command""" + + parser.add_argument( + '-collection_uuid', + '-c', + nargs=1, + type=str, + help='the uuid for the collection to create the content library in.' + ) + parser.add_argument( + 'library_ids', + nargs='*', + help='a space-seperated list of v1 library ids to copy' + ) + parser.add_argument( + '--all', + action='store_true', + dest='all', + help='Copy all libraries' + ) + parser.add_argument( + '--uncopy', + action='store_true', + dest='uncopy', + help='Delete libraries specified' + ) + + parser.add_argument( + 'output_csv', + nargs='?', + default=None, + help='a file path to write the tasks output to. Without this the result is simply logged.' + ) + + def _parse_library_key(self, raw_value): + """ Parses library key from string """ + result = CourseKey.from_string(raw_value) + + if not isinstance(result, LibraryLocator): + raise CommandError(f"Argument {raw_value} is not a library key") + return result + + def handle(self, *args, **options): # lint-amnesty, pylint: disable=unused-argument + """Parse args and generate tasks for copying content.""" + print(options) + + if (not options['library_ids'] and not options['all']) or (options['library_ids'] and options['all']): + raise CommandError("copy_libraries_from_v1_to_v2 requires one or more s or the --all flag.") + + if (not options['library_ids'] and not options['all']) or (options['library_ids'] and options['all']): + raise CommandError("copy_libraries_from_v1_to_v2 requires one or more s or the --all flag.") + + if options['all']: + store = modulestore() + if query_yes_no(self.CONFIRMATION_PROMPT, default="no"): + v1_library_keys = [ + library.location.library_key.replace(branch=None) for library in store.get_libraries() + ] + else: + return + else: + v1_library_keys = list(map(self._parse_library_key, options['library_ids'])) + + create_library_task_group = group([ + delete_v2_library_from_v1_library.s(str(v1_library_key), options['collection_uuid'][0]) + if options['uncopy'] + else create_v2_library_from_v1_library.s(str(v1_library_key), options['collection_uuid'][0]) + for v1_library_key in v1_library_keys + ]) + + group_result = create_library_task_group.apply_async().get() + if options['output_csv']: + with open(options['output_csv'][0], 'w', encoding='utf-8', newline='') as output_writer: + output_writer.writerow("v1_library_id", "v2_library_id", "status", "error_msg") + for result in group_result: + output_writer.write(result.keys()) + log.info(group_result) diff --git a/cms/djangoapps/contentstore/management/commands/delete_orphans.py b/cms/djangoapps/contentstore/management/commands/delete_orphans.py index 8ee199cd0f..1994adcde9 100644 --- a/cms/djangoapps/contentstore/management/commands/delete_orphans.py +++ b/cms/djangoapps/contentstore/management/commands/delete_orphans.py @@ -5,7 +5,7 @@ from django.core.management.base import BaseCommand, CommandError from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey -from cms.djangoapps.contentstore.xblock_services.xblock_service import delete_orphans +from cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers import delete_orphans from xmodule.modulestore import ModuleStoreEnum # lint-amnesty, pylint: disable=wrong-import-order diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/xblock.py b/cms/djangoapps/contentstore/rest_api/v1/views/xblock.py index d360d6aa02..94a87981d3 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/xblock.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/xblock.py @@ -9,12 +9,12 @@ from common.djangoapps.util.json_request import expect_json_in_class_view from ....api import course_author_access_required -from cms.djangoapps.contentstore.xblock_services import xblock_service +from cms.djangoapps.contentstore.xblock_storage_handlers import view_handlers import cms.djangoapps.contentstore.toggles as contentstore_toggles log = logging.getLogger(__name__) toggles = contentstore_toggles -handle_xblock = xblock_service.handle_xblock +handle_xblock = view_handlers.handle_xblock @view_auth_classes() diff --git a/cms/djangoapps/contentstore/signals/handlers.py b/cms/djangoapps/contentstore/signals/handlers.py index 3c0742adb0..fec46b6b74 100644 --- a/cms/djangoapps/contentstore/signals/handlers.py +++ b/cms/djangoapps/contentstore/signals/handlers.py @@ -177,8 +177,9 @@ def listen_for_xblock_published(sender, signal, **kwargs): Publish XBLOCK_PUBLISHED signals onto the event bus. """ if settings.FEATURES.get("ENABLE_SEND_XBLOCK_EVENTS_OVER_BUS"): + topic = getattr(settings, "EVENT_BUS_XBLOCK_LIFECYCLE_TOPIC", "course-authoring-xblock-lifecycle") get_producer().send( - signal=XBLOCK_PUBLISHED, topic='xblock-published', + signal=XBLOCK_PUBLISHED, topic=topic, event_key_field='xblock_info.usage_key', event_data={'xblock_info': kwargs['xblock_info']}, event_metadata=kwargs['metadata'], ) @@ -190,8 +191,9 @@ def listen_for_xblock_deleted(sender, signal, **kwargs): Publish XBLOCK_DELETED signals onto the event bus. """ if settings.FEATURES.get("ENABLE_SEND_XBLOCK_EVENTS_OVER_BUS"): + topic = getattr(settings, "EVENT_BUS_XBLOCK_LIFECYCLE_TOPIC", "course-authoring-xblock-lifecycle") get_producer().send( - signal=XBLOCK_DELETED, topic='xblock-deleted', + signal=XBLOCK_DELETED, topic=topic, event_key_field='xblock_info.usage_key', event_data={'xblock_info': kwargs['xblock_info']}, event_metadata=kwargs['metadata'], ) @@ -203,8 +205,9 @@ def listen_for_xblock_duplicated(sender, signal, **kwargs): Publish XBLOCK_DUPLICATED signals onto the event bus. """ if settings.FEATURES.get("ENABLE_SEND_XBLOCK_EVENTS_OVER_BUS"): + topic = getattr(settings, "EVENT_BUS_XBLOCK_LIFECYCLE_TOPIC", "course-authoring-xblock-lifecycle") get_producer().send( - signal=XBLOCK_DUPLICATED, topic='xblock-duplicated', + signal=XBLOCK_DUPLICATED, topic=topic, event_key_field='xblock_info.usage_key', event_data={'xblock_info': kwargs['xblock_info']}, event_metadata=kwargs['metadata'], ) diff --git a/cms/djangoapps/contentstore/tasks.py b/cms/djangoapps/contentstore/tasks.py index a2c536227f..d452b2f004 100644 --- a/cms/djangoapps/contentstore/tasks.py +++ b/cms/djangoapps/contentstore/tasks.py @@ -19,6 +19,7 @@ from django.conf import settings from django.contrib.auth import get_user_model from django.core.exceptions import SuspiciousOperation from django.core.files import File +from django.db.transaction import atomic from django.test import RequestFactory from django.utils.text import get_valid_filename from edx_django_utils.monitoring import ( @@ -30,9 +31,10 @@ from edx_django_utils.monitoring import ( from olxcleaner.exceptions import ErrorLevel from olxcleaner.reporting import report_error_summary, report_errors from opaque_keys.edx.keys import CourseKey -from opaque_keys.edx.locator import LibraryLocator +from opaque_keys.edx.locator import LibraryLocator, LibraryLocatorV2 from organizations.api import add_organization_course, ensure_organization -from organizations.models import OrganizationCourse +from organizations.exceptions import InvalidOrganizationException +from organizations.models import Organization, OrganizationCourse from path import Path as path from pytz import UTC from user_tasks.models import UserTaskArtifact, UserTaskStatus @@ -47,13 +49,17 @@ from cms.djangoapps.contentstore.courseware_index import ( from cms.djangoapps.contentstore.storage import course_import_export_storage from cms.djangoapps.contentstore.utils import initialize_permissions, reverse_usage_url, translation_language from cms.djangoapps.models.settings.course_metadata import CourseMetadata + from common.djangoapps.course_action_state.models import CourseRerunState from common.djangoapps.student.auth import has_course_author_access +from common.djangoapps.student.roles import CourseInstructorRole, CourseStaffRole, LibraryUserRole from common.djangoapps.util.monitoring import monitor_import_failure from openedx.core.djangoapps.content.learning_sequences.api import key_supports_outlines +from openedx.core.djangoapps.content_libraries import api as v2contentlib_api from openedx.core.djangoapps.course_apps.toggles import exams_ida_enabled from openedx.core.djangoapps.discussions.tasks import update_unit_discussion_state_from_discussion_blocks from openedx.core.djangoapps.embargo.models import CountryAccessRule, RestrictedCourse +from openedx.core.lib.blockstore_api import get_collection from openedx.core.lib.extract_tar import safetar_extractall from xmodule.contentstore.django import contentstore # lint-amnesty, pylint: disable=wrong-import-order from xmodule.course_block import CourseFields # lint-amnesty, pylint: disable=wrong-import-order @@ -790,7 +796,6 @@ def log_errors_to_artifact(errorstore, status): def handle_course_import_exception(courselike_key, exception, status, known=True): """ Handle course import exception and fail task status. - Arguments: courselike_key: A locator identifies a course resource. exception: Exception object @@ -808,3 +813,159 @@ def handle_course_import_exception(courselike_key, exception, status, known=True if status.state != UserTaskStatus.FAILED: status.fail(task_fail_message) + + +def _parse_organization(org_name): + """Find a matching organization name, if one does not exist, specify that this is the *unspecfied* organization""" + try: + ensure_organization(org_name) + except InvalidOrganizationException: + return 'None' + return Organization.objects.get(short_name=org_name) + + +def copy_v1_user_roles_into_v2_library(v2_library_key, v1_library_key): + """ + write the access and edit permissions of a v1 library into a v2 library. + """ + + def _get_users_by_access_level(v1_library_key): + """ + Get a permissions object for a library which contains a list of user IDs for every V2 permissions level, + based on V1 library roles. + The following mapping exists for a library: + V1 Library Role -> V2 Permission Level + LibraryUserRole -> READ_LEVEL + CourseStaffRole -> AUTHOR_LEVEL + CourseInstructorRole -> ADMIN_LEVEL + """ + permissions = {} + permissions[v2contentlib_api.AccessLevel.READ_LEVEL] = list(LibraryUserRole(v1_library_key).users_with_role()) + permissions[v2contentlib_api.AccessLevel.AUTHOR_LEVEL] = list(CourseStaffRole(v1_library_key).users_with_role()) + permissions[v2contentlib_api.AccessLevel.ADMIN_LEVEL] = list( + CourseInstructorRole(v1_library_key).users_with_role() + ) + return permissions + + permissions = _get_users_by_access_level(v1_library_key) + for access_level in permissions.keys(): # lint-amnesty, pylint: disable=consider-iterating-dictionary + for user in permissions[access_level]: + v2contentlib_api.set_library_user_permissions(v2_library_key, user, access_level) + + +def _create_copy_content_task(v2_library_key, v1_library_key): + """ + spin up a celery task to import the V1 Library's content into the V2 library. + This utalizes the fact that course and v1 library content is stored almost identically. + """ + return v2contentlib_api.import_blocks_create_task(v2_library_key, v1_library_key) + + +def _create_metadata(v1_library_key, collection_uuid): + """instansiate an index for the V2 lib in the collection""" + + store = modulestore() + v1_library = store.get_library(v1_library_key) + collection = get_collection(collection_uuid).uuid + # To make it easy, all converted libs are complex, meaning they can contain problems, videos, and text + library_type = 'complex' + org = _parse_organization(v1_library.location.library_key.org) + slug = v1_library.location.library_key.library + title = v1_library.display_name + # V1 libraries do not have descriptions. + description = '' + # permssions & license are most restrictive. + allow_public_learning = False + allow_public_read = False + library_license = '' # '' = ALL_RIGHTS_RESERVED + with atomic(): + return v2contentlib_api.create_library( + collection, + library_type, + org, + slug, + title, + description, + allow_public_learning, + allow_public_read, + library_license + ) + + +@shared_task(time_limit=30) +@set_code_owner_attribute +def delete_v2_library_from_v1_library(v1_library_key_string, collection_uuid): + """ + For a V1 Library, delete the matching v2 library, where the library is the result of the copy operation + This method relys on _create_metadata failling for LibraryAlreadyExists in order to obtain the v2 slug. + """ + v1_library_key = CourseKey.from_string(v1_library_key_string) + v2_library_key = LibraryLocatorV2.from_string('lib:' + v1_library_key.org + ':' + v1_library_key.course) + + try: + v2contentlib_api.delete_library(v2_library_key) + return { + "v1_library_id": v1_library_key_string, + "v2_library_id": v2_library_key, + "status": "SUCCESS", + "msg": None + } + except Exception as error: # lint-amnesty, pylint: disable=broad-except + return { + "v1_library_id": v1_library_key_string, + "v2_library_id": v2_library_key, + "status": "FAILED", + "msg": f"Exception: {v2_library_key} did not delete: {error}" + } + + +@shared_task(time_limit=30) +@set_code_owner_attribute +def create_v2_library_from_v1_library(v1_library_key_string, collection_uuid): + """ + write the metadata, permissions, and content of a v1 library into a v2 library in the given collection. + """ + + v1_library_key = CourseKey.from_string(v1_library_key_string) + + LOGGER.info(f"Copy Library task created for library: {v1_library_key}") + + try: + v2_library_metadata = _create_metadata(v1_library_key, collection_uuid) + + except v2contentlib_api.LibraryAlreadyExists: + return { + "v1_library_id": v1_library_key_string, + "v2_library_id": None, + "status": "FAILED", + "msg": f"Exception: LibraryAlreadyExists {v1_library_key_string} aleady exists" + } + + try: + _create_copy_content_task(v2_library_metadata.key, v1_library_key) + except Exception as error: # lint-amnesty, pylint: disable=broad-except + return { + "v1_library_id": v1_library_key_string, + "v2_library_id": str(v2_library_metadata.key), + "status": "FAILED", + "msg": + f"Could not import content from {v1_library_key_string} into {str(v2_library_metadata.key)}: {str(error)}" + } + + try: + copy_v1_user_roles_into_v2_library(v2_library_metadata.key, v1_library_key) + except Exception as error: # lint-amnesty, pylint: disable=broad-except + return { + "v1_library_id": v1_library_key_string, + "v2_library_id": str(v2_library_metadata.key), + "status": "FAILED", + "msg": + f"Could not copy permissions from {v1_library_key_string} into {str(v2_library_metadata.key)}: {str(error)}" + } + + return { + "v1_library_id": v1_library_key_string, + "v2_library_id": str(v2_library_metadata.key), + "status": "SUCCESS", + "msg": None + } diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index f7046cc01a..9162598645 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -1080,6 +1080,36 @@ class ContentStoreTest(ContentStoreTestCase): """Test new course creation - happy path""" self.assert_created_course() + @ddt.data(True, False) + @mock.patch( + 'cms.djangoapps.contentstore.views.course.default_enable_flexible_peer_openassessments' + ) + def test_create_course__default_enable_flexible_peer_openassessments( + self, + mock_toggle_state, + mock_default_enable_flexible_peer_openassessments + ): + """ + Test that flex peer grading is forced on, when enabled + """ + # Given a new course run + test_course_data = {} + test_course_data.update(self.course_data) + course_key = _get_course_id(self.store, test_course_data) + + # ... with org configured to / not to enable flex grading + mock_default_enable_flexible_peer_openassessments.return_value = mock_toggle_state + + # When I create a new course + new_course_data = _create_course(self, course_key, test_course_data) + + # Then the process completes successfully + new_course_key = CourseKey.from_string(new_course_data['course_key']) + new_course = self.store.get_course(new_course_key) + + # ... and our setting got toggled appropriately on the course + self.assertEqual(new_course.force_on_flexible_peer_openassessments, mock_toggle_state) + @override_settings(DEFAULT_COURSE_LANGUAGE='hr') def test_create_course_default_language(self): """Test new course creation and verify default language""" @@ -2104,6 +2134,8 @@ class EntryPageTestCase(TestCase): def _create_course(test, course_key, course_data): """ Creates a course via an AJAX request and verifies the URL returned in the response. + + Returns the data of the POST response """ course_url = get_url('course_handler', course_key, 'course_key_string') response = test.client.ajax_post(course_url, course_data) @@ -2112,6 +2144,8 @@ def _create_course(test, course_key, course_data): test.assertNotIn('ErrMsg', data) test.assertEqual(data['url'], course_url) + return data + def _get_course_id(store, course_data): """Returns the course ID.""" diff --git a/cms/djangoapps/contentstore/tests/test_course_create_rerun.py b/cms/djangoapps/contentstore/tests/test_course_create_rerun.py index 1d1b1177d0..264d6de8ff 100644 --- a/cms/djangoapps/contentstore/tests/test_course_create_rerun.py +++ b/cms/djangoapps/contentstore/tests/test_course_create_rerun.py @@ -4,6 +4,7 @@ Test view handler for rerun (and eventually create) import datetime +from itertools import product from unittest import mock import ddt @@ -317,3 +318,54 @@ class TestCourseListing(ModuleStoreTestCase): 'run': '2021_T1' }) self.assertEqual(response.status_code, 403) + + @ddt.data(*product([True, False], [True, False])) + @ddt.unpack + @mock.patch( + 'cms.djangoapps.contentstore.views.course.default_enable_flexible_peer_openassessments' + ) + def test_default_enable_flexible_peer_openassessments_on_rerun( + self, + mock_toggle_state, + mock_original_course_setting, + mock_default_enable_flexible_peer_openassessments + ): + """ + Test that flex peer grading is forced on, when enabled + """ + # Given a valid course to rerun + add_organization({ + 'name': 'Test Flex Grading', + 'short_name': self.source_course_key.org, + 'description': 'Test roll-forward of flex grading setting', + }) + source_course = self.store.get_course(self.source_course_key) + source_course.force_on_flexible_peer_openassessments = mock_original_course_setting + self.store.update_item(source_course, self.user.id) + mock_default_enable_flexible_peer_openassessments.return_value = mock_toggle_state + + # When I create a new course + response = self.client.ajax_post(self.course_create_rerun_url, { + 'source_course_key': str(self.source_course_key), + 'org': self.source_course_key.org, + 'course': self.source_course_key.course, + 'run': 'copy', + 'display_name': 'New, exciting course!', + }) + + # Then the process completes successfully + self.assertEqual(response.status_code, 200) + + data = parse_json(response) + dest_course_key = CourseKey.from_string(data['destination_course_key']) + dest_course = self.store.get_course(dest_course_key) + + # ... and our setting got enabled appropriately on our new course + if mock_toggle_state: + self.assertTrue(dest_course.force_on_flexible_peer_openassessments) + # ... or preserved if the default enable setting is not on + else: + self.assertEqual( + source_course.force_on_flexible_peer_openassessments, + dest_course.force_on_flexible_peer_openassessments + ) diff --git a/cms/djangoapps/contentstore/tests/test_i18n.py b/cms/djangoapps/contentstore/tests/test_i18n.py index 8f9674fcc3..57e8626d43 100644 --- a/cms/djangoapps/contentstore/tests/test_i18n.py +++ b/cms/djangoapps/contentstore/tests/test_i18n.py @@ -1,8 +1,6 @@ """ Tests for validate Internationalization and XBlock i18n service. """ - - import gettext from unittest import mock, skip @@ -17,7 +15,6 @@ from xmodule.tests.test_export import PureXBlock from cms.djangoapps.contentstore.tests.utils import AjaxEnabledTestClient from cms.djangoapps.contentstore.views.preview import _prepare_runtime_for_preview from common.djangoapps.student.tests.factories import UserFactory -from openedx.core.lib.edx_six import get_gettext class FakeTranslations(XBlockI18nService): @@ -68,8 +65,13 @@ class TestXBlockI18nService(ModuleStoreTestCase): self.test_language = 'dummy language' self.request = mock.Mock() self.course = CourseFactory.create() + self.field_data = mock.Mock() self.block = BlockFactory(category="pure", parent=self.course) - _prepare_runtime_for_preview(self.request, self.block) + _prepare_runtime_for_preview( + self.request, + self.block, + self.field_data, + ) self.addCleanup(translation.deactivate) def get_block_i18n_service(self, block): @@ -94,7 +96,7 @@ class TestXBlockI18nService(ModuleStoreTestCase): def __init__(self, module): self.module = module - self.old_ugettext = get_gettext(module) + self.old_ugettext = module.gettext def __enter__(self): def new_ugettext(*args, **kwargs): @@ -152,9 +154,9 @@ class TestXBlockI18nService(ModuleStoreTestCase): with mock.patch('gettext.translation', return_value=_translator(domain='text', localedir=localedir, languages=[get_language()])): i18n_service = self.get_block_i18n_service(self.block) - self.assertEqual(get_gettext(i18n_service)('Hello'), 'Hello') - self.assertNotEqual(get_gettext(i18n_service)('Hello'), 'fr-hello-world') - self.assertNotEqual(get_gettext(i18n_service)('Hello'), 'es-hello-world') + self.assertEqual(i18n_service.gettext('Hello'), 'Hello') + self.assertNotEqual(i18n_service.gettext('Hello'), 'fr-hello-world') + self.assertNotEqual(i18n_service.gettext('Hello'), 'es-hello-world') translation.activate("fr") with mock.patch('gettext.translation', return_value=_translator(domain='text', localedir=localedir, diff --git a/cms/djangoapps/contentstore/toggles.py b/cms/djangoapps/contentstore/toggles.py index 87a551d0a8..438f99f97b 100644 --- a/cms/djangoapps/contentstore/toggles.py +++ b/cms/djangoapps/contentstore/toggles.py @@ -238,7 +238,7 @@ def use_new_home_page(): return ENABLE_NEW_STUDIO_HOME_PAGE.is_enabled() -# .. toggle_name: new_studio_mfe.use_new_custom_pages +# .. toggle_name: contentstore.new_studio_mfe.use_new_custom_pages # .. toggle_implementation: CourseWaffleFlag # .. toggle_default: False # .. toggle_description: This flag enables the use of the new studio custom pages mfe @@ -258,7 +258,7 @@ def use_new_custom_pages(course_key): return ENABLE_NEW_STUDIO_CUSTOM_PAGES.is_enabled(course_key) -# .. toggle_name: new_studio_mfe.use_new_schedule_details_page +# .. toggle_name: contentstore.new_studio_mfe.use_new_schedule_details_page # .. toggle_implementation: CourseWaffleFlag # .. toggle_default: False # .. toggle_description: This flag enables the use of the new studio schedule and details mfe @@ -278,7 +278,7 @@ def use_new_schedule_details_page(course_key): return ENABLE_NEW_STUDIO_SCHEDULE_DETAILS_PAGE.is_enabled(course_key) -# .. toggle_name: new_studio_mfe.use_new_advanced_settings_page +# .. toggle_name: contentstore.new_studio_mfe.use_new_advanced_settings_page # .. toggle_implementation: CourseWaffleFlag # .. toggle_default: False # .. toggle_description: This flag enables the use of the new studio advanced settings page mfe @@ -298,7 +298,7 @@ def use_new_advanced_settings_page(course_key): return ENABLE_NEW_STUDIO_ADVANCED_SETTINGS_PAGE.is_enabled(course_key) -# .. toggle_name: new_studio_mfe.use_new_grading_page +# .. toggle_name: contentstore.new_studio_mfe.use_new_grading_page # .. toggle_implementation: CourseWaffleFlag # .. toggle_default: False # .. toggle_description: This flag enables the use of the new studio grading page mfe @@ -318,7 +318,7 @@ def use_new_grading_page(course_key): return ENABLE_NEW_STUDIO_GRADING_PAGE.is_enabled(course_key) -# .. toggle_name: new_studio_mfe.use_new_updates_page +# .. toggle_name: contentstore.new_studio_mfe.use_new_updates_page # .. toggle_implementation: CourseWaffleFlag # .. toggle_default: False # .. toggle_description: This flag enables the use of the new studio updates page mfe @@ -338,7 +338,7 @@ def use_new_updates_page(course_key): return ENABLE_NEW_STUDIO_UPDATES_PAGE.is_enabled(course_key) -# .. toggle_name: new_studio_mfe.use_new_import_page +# .. toggle_name: contentstore.new_studio_mfe.use_new_import_page # .. toggle_implementation: CourseWaffleFlag # .. toggle_default: False # .. toggle_description: This flag enables the use of the new studio import page mfe @@ -358,7 +358,7 @@ def use_new_import_page(course_key): return ENABLE_NEW_STUDIO_IMPORT_PAGE.is_enabled(course_key) -# .. toggle_name: new_studio_mfe.use_new_export_page +# .. toggle_name: contentstore.new_studio_mfe.use_new_export_page # .. toggle_implementation: CourseWaffleFlag # .. toggle_default: False # .. toggle_description: This flag enables the use of the new studio export page mfe @@ -378,7 +378,7 @@ def use_new_export_page(course_key): return ENABLE_NEW_STUDIO_EXPORT_PAGE.is_enabled(course_key) -# .. toggle_name: new_studio_mfe.use_new_files_uploads_page +# .. toggle_name: contentstore.new_studio_mfe.use_new_files_uploads_page # .. toggle_implementation: CourseWaffleFlag # .. toggle_default: False # .. toggle_description: This flag enables the use of the new studio files and uploads page mfe @@ -398,7 +398,7 @@ def use_new_files_uploads_page(course_key): return ENABLE_NEW_STUDIO_FILES_UPLOADS_PAGE.is_enabled(course_key) -# .. toggle_name: new_studio_mfe.use_new_video_uploads_page +# .. toggle_name: contentstore.new_studio_mfe.use_new_video_uploads_page # .. toggle_implementation: CourseWaffleFlag # .. toggle_default: False # .. toggle_description: This flag enables the use of the new video uploads page mfe @@ -418,7 +418,7 @@ def use_new_video_uploads_page(course_key): return ENABLE_NEW_STUDIO_VIDEO_UPLOADS_PAGE.is_enabled(course_key) -# .. toggle_name: new_studio_mfe.use_new_course_outline_page +# .. toggle_name: contentstore.new_studio_mfe.use_new_course_outline_page # .. toggle_implementation: CourseWaffleFlag # .. toggle_default: False # .. toggle_description: This flag enables the use of the new studio course outline page mfe @@ -438,7 +438,7 @@ def use_new_course_outline_page(course_key): return ENABLE_NEW_STUDIO_COURSE_OUTLINE_PAGE.is_enabled(course_key) -# .. toggle_name: new_studio_mfe.use_new_unit_page +# .. toggle_name: contentstore.new_studio_mfe.use_new_unit_page # .. toggle_implementation: CourseWaffleFlag # .. toggle_default: False # .. toggle_description: This flag enables the use of the new studio course outline page mfe @@ -458,7 +458,7 @@ def use_new_unit_page(course_key): return ENABLE_NEW_STUDIO_UNIT_PAGE.is_enabled(course_key) -# .. toggle_name: new_studio_mfe.use_new_course_team_page +# .. toggle_name: contentstore.new_studio_mfe.use_new_course_team_page # .. toggle_implementation: CourseWaffleFlag # .. toggle_default: False # .. toggle_description: This flag enables the use of the new studio course team page mfe @@ -476,3 +476,26 @@ def use_new_course_team_page(course_key): Returns a boolean if new studio course team mfe is enabled """ return ENABLE_NEW_STUDIO_COURSE_TEAM_PAGE.is_enabled(course_key) + + +# .. toggle_name: contentstore.default_enable_flexible_peer_openassessments +# .. toggle_implementation: CourseWaffleFlag +# .. toggle_default: False +# .. toggle_description: This flag turns on the force_on_flexible_peer_openassessments +# setting for course reruns or new courses, where enabled. +# .. toggle_use_cases: temporary +# .. toggle_creation_date: 2023-06-27 +# .. toggle_target_removal_date: 2024-01-27 +# .. toggle_tickets: AU-1289 +# .. toggle_warning: +DEFAULT_ENABLE_FLEXIBLE_PEER_OPENASSESSMENTS = CourseWaffleFlag( + f'{CONTENTSTORE_NAMESPACE}.default_enable_flexible_peer_openassessments', __name__) + + +def default_enable_flexible_peer_openassessments(course_key): + """ + Returns a boolean if ORA flexible peer grading should be toggled on for a + course rerun or new course. We expect this to be set at the organization + level to opt in/out of rolling forward this feature. + """ + return DEFAULT_ENABLE_FLEXIBLE_PEER_OPENASSESSMENTS.is_enabled(course_key) diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py index 414f00d002..20c6237ed7 100644 --- a/cms/djangoapps/contentstore/utils.py +++ b/cms/djangoapps/contentstore/utils.py @@ -70,6 +70,7 @@ from cms.djangoapps.contentstore.toggles import ( use_new_unit_page, use_new_updates_page, use_new_video_uploads_page, + use_new_custom_pages, ) from cms.djangoapps.contentstore.toggles import use_new_text_editor, use_new_video_editor from cms.djangoapps.models.settings.course_grading import CourseGradingModel @@ -402,19 +403,32 @@ def get_course_outline_url(course_locator) -> str: return course_outline_url -def get_unit_url(course_locator) -> str: +def get_unit_url(course_locator, unit_locator) -> str: """ Gets course authoring microfrontend URL for unit page view. """ unit_url = None if use_new_unit_page(course_locator): mfe_base_url = get_course_authoring_url(course_locator) - course_mfe_url = f'{mfe_base_url}/container/' + course_mfe_url = f'{mfe_base_url}/course/{course_locator}/container/{unit_locator}' if mfe_base_url: unit_url = course_mfe_url return unit_url +def get_custom_pages_url(course_locator) -> str: + """ + Gets course authoring microfrontend URL for custom pages view. + """ + custom_pages_url = None + if use_new_custom_pages(course_locator): + mfe_base_url = get_course_authoring_url(course_locator) + course_mfe_url = f'{mfe_base_url}/course/{course_locator}/custom-pages' + if mfe_base_url: + custom_pages_url = course_mfe_url + return custom_pages_url + + def course_import_olx_validation_is_enabled(): """ Check if course olx validation is enabled on course import. diff --git a/cms/djangoapps/contentstore/views/assets.py b/cms/djangoapps/contentstore/views/assets.py index 38f11db62c..d2adb429a8 100644 --- a/cms/djangoapps/contentstore/views/assets.py +++ b/cms/djangoapps/contentstore/views/assets.py @@ -12,6 +12,7 @@ from django.conf import settings from django.contrib.auth.decorators import login_required from django.core.exceptions import PermissionDenied from django.http import HttpResponseBadRequest, HttpResponseNotFound +from django.shortcuts import redirect from django.utils.translation import gettext as _ from django.views.decorators.csrf import ensure_csrf_cookie from django.views.decorators.http import require_http_methods, require_POST @@ -31,7 +32,8 @@ from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disa from xmodule.modulestore.exceptions import ItemNotFoundError # lint-amnesty, pylint: disable=wrong-import-order from ..exceptions import AssetNotFoundException, AssetSizeTooLargeException -from ..utils import reverse_course_url +from ..toggles import use_new_files_uploads_page +from ..utils import reverse_course_url, get_files_uploads_url __all__ = ['assets_handler'] @@ -104,6 +106,9 @@ def _asset_index(request, course_key): ''' course_block = modulestore().get_course(course_key) + if use_new_files_uploads_page(course_key): + return redirect(get_files_uploads_url(course_key)) + return render_to_response('asset_index.html', { 'language_code': request.LANGUAGE_CODE, 'context_course': course_block, diff --git a/cms/djangoapps/contentstore/views/block.py b/cms/djangoapps/contentstore/views/block.py index d522a8bc04..91078ccd11 100644 --- a/cms/djangoapps/contentstore/views/block.py +++ b/cms/djangoapps/contentstore/views/block.py @@ -43,15 +43,16 @@ from ..helpers import ( ) from .preview import get_preview_fragment -from cms.djangoapps.contentstore.xblock_services import ( +from cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers import ( handle_xblock, create_xblock_info, load_services_for_studio, get_block_info, get_xblock, delete_orphans, - usage_key_with_run, ) +from cms.djangoapps.contentstore.xblock_storage_handlers.xblock_helpers import usage_key_with_run + __all__ = [ "orphan_handler", diff --git a/cms/djangoapps/contentstore/views/component.py b/cms/djangoapps/contentstore/views/component.py index 2533141b2c..05bd39a355 100644 --- a/cms/djangoapps/contentstore/views/component.py +++ b/cms/djangoapps/contentstore/views/component.py @@ -10,6 +10,7 @@ from django.conf import settings from django.contrib.auth.decorators import login_required from django.core.exceptions import PermissionDenied from django.http import Http404, HttpResponseBadRequest +from django.shortcuts import redirect from django.utils.translation import gettext as _ from django.views.decorators.http import require_GET from opaque_keys import InvalidKeyError @@ -34,10 +35,10 @@ except ImportError: content_staging_api = None from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order from xmodule.modulestore.exceptions import ItemNotFoundError # lint-amnesty, pylint: disable=wrong-import-order - -from ..utils import get_lms_link_for_item, get_sibling_urls, reverse_course_url +from ..toggles import use_new_unit_page +from ..utils import get_lms_link_for_item, get_sibling_urls, reverse_course_url, get_unit_url from ..helpers import get_parent_xblock, is_unit, xblock_type_display_name -from cms.djangoapps.contentstore.xblock_services.xblock_service import ( +from cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers import ( add_container_page_publishing_info, create_xblock_info, load_services_for_studio, @@ -131,7 +132,6 @@ def container_handler(request, usage_key_string): course, xblock, lms_link, preview_lms_link = _get_item_in_course(request, usage_key) except ItemNotFoundError: return HttpResponseBadRequest() - component_templates = get_component_templates(course) ancestor_xblocks = [] parent = get_parent_xblock(xblock) @@ -140,6 +140,9 @@ def container_handler(request, usage_key_string): is_unit_page = is_unit(xblock) unit = xblock if is_unit_page else None + if is_unit_page and use_new_unit_page(course.id): + return redirect(get_unit_url(course.id, unit.location)) + is_first = True block = xblock @@ -199,7 +202,6 @@ def container_handler(request, usage_key_string): user_clipboard = content_staging_api.get_user_clipboard_json(request.user.id, request) else: user_clipboard = {"content": None} - return render_to_response('container.html', { 'language_code': request.LANGUAGE_CODE, 'context_course': course, # Needed only for display of menus at top of page. diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index 99669e5a72..0b33525c99 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -85,13 +85,28 @@ from ..course_group_config import ( from ..course_info_model import delete_course_update, get_course_updates, update_course_updates from ..courseware_index import CoursewareSearchIndexer, SearchIndexingError from ..tasks import rerun_course as rerun_course_task -from ..toggles import split_library_view_on_dashboard +from ..toggles import ( + default_enable_flexible_peer_openassessments, + split_library_view_on_dashboard, + use_new_course_outline_page, + use_new_home_page, + use_new_updates_page, + use_new_advanced_settings_page, + use_new_grading_page, + use_new_schedule_details_page +) from ..utils import ( add_instructor, get_course_settings, get_course_grading, get_lms_link_for_item, get_proctored_exam_settings_url, + get_course_outline_url, + get_studio_home_url, + get_updates_url, + get_advanced_settings_url, + get_grading_url, + get_schedule_details_url, initialize_permissions, remove_all_instructors, reverse_course_url, @@ -103,7 +118,7 @@ from ..utils import ( ) from .component import ADVANCED_COMPONENT_TYPES from ..helpers import is_content_creator -from cms.djangoapps.contentstore.xblock_services.xblock_service import ( +from cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers import ( create_xblock_info, ) from .library import ( @@ -533,6 +548,8 @@ def course_listing(request): """ List all courses and libraries available to the logged in user """ + if use_new_home_page(): + return redirect(get_studio_home_url()) optimization_enabled = GlobalStaff().has_user(request.user) and ENABLE_GLOBAL_STAFF_OPTIMIZATION.is_enabled() @@ -691,6 +708,8 @@ def course_index(request, course_key): course_block = get_course_and_check_access(course_key, request.user, depth=None) if not course_block: raise Http404 + if use_new_course_outline_page(course_key): + return redirect(get_course_outline_url(course_key)) lms_link = get_lms_link_for_item(course_block.location) reindex_link = None if settings.FEATURES.get('ENABLE_COURSEWARE_INDEX', False): @@ -975,6 +994,12 @@ def create_new_course(user, org, number, run, fields): new_course = create_new_course_in_store(store_for_new_course, user, org, number, run, fields) add_organization_course(org_data, new_course.id) update_course_discussions_settings(new_course.id) + + # Enable certain fields rolling forward, where configured + if default_enable_flexible_peer_openassessments(new_course.id): + new_course.force_on_flexible_peer_openassessments = True + modulestore().update_item(new_course, new_course.published_by) + return new_course @@ -1038,6 +1063,10 @@ def rerun_course(user, source_course_key, org, number, run, fields, background=T fields['enrollment_end'] = None fields['video_upload_pipeline'] = {} + # Enable certain fields rolling forward, where configured + if default_enable_flexible_peer_openassessments(source_course_key): + fields['force_on_flexible_peer_openassessments'] = True + json_fields = json.dumps(fields, cls=EdxJSONEncoder) args = [str(source_course_key), str(destination_course_key), user.id, json_fields] @@ -1066,6 +1095,8 @@ def course_info_handler(request, course_key_string): course_block = get_course_and_check_access(course_key, request.user) if not course_block: raise Http404 + if use_new_updates_page(course_key): + return redirect(get_updates_url(course_key)) if 'text/html' in request.META.get('HTTP_ACCEPT', 'text/html'): return render_to_response( 'course_info.html', @@ -1150,6 +1181,8 @@ def settings_handler(request, course_key_string): # lint-amnesty, pylint: disab with modulestore().bulk_operations(course_key): course_block = get_course_and_check_access(course_key, request.user) if 'text/html' in request.META.get('HTTP_ACCEPT', '') and request.method == 'GET': + if use_new_schedule_details_page(course_key): + return redirect(get_schedule_details_url(course_key)) settings_context = get_course_settings(request, course_key, course_block) return render_to_response('settings.html', settings_context) elif 'application/json' in request.META.get('HTTP_ACCEPT', ''): # pylint: disable=too-many-nested-blocks @@ -1191,6 +1224,8 @@ def grading_handler(request, course_key_string, grader_index=None): raise PermissionDenied() if 'text/html' in request.META.get('HTTP_ACCEPT', '') and request.method == 'GET': + if use_new_grading_page(course_key): + return redirect(get_grading_url(course_key)) grading_context = get_course_grading(course_key) return render_to_response('settings_graders.html', grading_context) elif 'application/json' in request.META.get('HTTP_ACCEPT', ''): @@ -1286,6 +1321,8 @@ def advanced_settings_handler(request, course_key_string): advanced_dict.get('mobile_available')['deprecated'] = True if 'text/html' in request.META.get('HTTP_ACCEPT', '') and request.method == 'GET': + if use_new_advanced_settings_page(course_key): + return redirect(get_advanced_settings_url(course_key)) publisher_enabled = configuration_helpers.get_value_for_org( course_block.location.org, 'ENABLE_PUBLISHER', diff --git a/cms/djangoapps/contentstore/views/entrance_exam.py b/cms/djangoapps/contentstore/views/entrance_exam.py index b58f0d84bf..bbefb0e9e8 100644 --- a/cms/djangoapps/contentstore/views/entrance_exam.py +++ b/cms/djangoapps/contentstore/views/entrance_exam.py @@ -24,8 +24,8 @@ from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disa from xmodule.modulestore.exceptions import ItemNotFoundError # lint-amnesty, pylint: disable=wrong-import-order from ..helpers import remove_entrance_exam_graders -from ..xblock_services.create_xblock import create_xblock -from cms.djangoapps.contentstore.xblock_services.xblock_service import delete_item +from cms.djangoapps.contentstore.xblock_storage_handlers.create_xblock import create_xblock +from cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers import delete_item __all__ = ['entrance_exam', ] diff --git a/cms/djangoapps/contentstore/views/import_export.py b/cms/djangoapps/contentstore/views/import_export.py index 1e3d1487cc..bb1616c04e 100644 --- a/cms/djangoapps/contentstore/views/import_export.py +++ b/cms/djangoapps/contentstore/views/import_export.py @@ -19,6 +19,7 @@ from django.core.files import File from django.core.files.storage import FileSystemStorage from django.db import transaction from django.http import Http404, HttpResponse, HttpResponseNotFound, StreamingHttpResponse +from django.shortcuts import redirect from django.utils.translation import gettext as _ from django.views.decorators.cache import cache_control from django.views.decorators.csrf import ensure_csrf_cookie @@ -40,7 +41,8 @@ from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disa from ..storage import course_import_export_storage from ..tasks import CourseExportTask, CourseImportTask, export_olx, import_olx -from ..utils import reverse_course_url, reverse_library_url +from ..toggles import use_new_export_page, use_new_import_page +from ..utils import reverse_course_url, reverse_library_url, get_export_url, get_import_url __all__ = [ 'import_handler', 'import_status_handler', @@ -89,6 +91,8 @@ def import_handler(request, course_key_string): else: return _write_chunk(request, courselike_key) elif request.method == 'GET': # assume html + if use_new_import_page(courselike_key): + return redirect(get_import_url(courselike_key)) status_url = reverse_course_url( "import_status_handler", courselike_key, kwargs={'filename': "fillerName"} ) @@ -336,6 +340,8 @@ def export_handler(request, course_key_string): export_olx.delay(request.user.id, course_key_string, request.LANGUAGE_CODE) return JsonResponse({'ExportStatus': 1}) elif 'text/html' in requested_format: + if use_new_export_page(course_key): + return redirect(get_export_url(course_key)) return render_to_response('export.html', context) else: # Only HTML request format is supported (no JSON). diff --git a/cms/djangoapps/contentstore/views/library.py b/cms/djangoapps/contentstore/views/library.py index efcad6e603..7a9c655787 100644 --- a/cms/djangoapps/contentstore/views/library.py +++ b/cms/djangoapps/contentstore/views/library.py @@ -43,7 +43,7 @@ from ..config.waffle import REDIRECT_TO_LIBRARY_AUTHORING_MICROFRONTEND from ..utils import add_instructor, reverse_library_url from .component import CONTAINER_TEMPLATES, get_component_templates from ..helpers import is_content_creator -from cms.djangoapps.contentstore.xblock_services.xblock_service import create_xblock_info +from cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers import create_xblock_info from .user import user_with_role __all__ = ['library_handler', 'manage_library_users'] diff --git a/cms/djangoapps/contentstore/views/preview.py b/cms/djangoapps/contentstore/views/preview.py index 2cde6b3373..c633ded939 100644 --- a/cms/djangoapps/contentstore/views/preview.py +++ b/cms/djangoapps/contentstore/views/preview.py @@ -24,7 +24,7 @@ from xmodule.partitions.partitions_service import PartitionService from xmodule.services import SettingsService, TeamsConfigurationService from xmodule.studio_editable import has_author_view from xmodule.util.sandboxing import SandboxService -from xmodule.util.xmodule_django import add_webpack_to_fragment +from xmodule.util.builtin_assets import add_webpack_js_to_fragment from xmodule.x_module import AUTHOR_VIEW, PREVIEW_VIEWS, STUDENT_VIEW, XModuleMixin from cms.djangoapps.xblock_config.models import StudioConfig from cms.djangoapps.contentstore.toggles import individualize_anonymous_user_id, ENABLE_COPY_PASTE_FEATURE @@ -149,13 +149,14 @@ def preview_layout_asides(block, context, frag, view_name, aside_frag_fns, wrap_ return result -def _prepare_runtime_for_preview(request, block): +def _prepare_runtime_for_preview(request, block, field_data): """ Sets properties in the runtime of the specified block that is required for rendering block previews. request: The active django request block: An XBlock + field_data: Wrapped field data for previews """ course_id = block.location.course_key @@ -198,6 +199,7 @@ def _prepare_runtime_for_preview(request, block): deprecated_anonymous_user_id = anonymous_id_for_user(request.user, None) services = { + "field-data": field_data, "i18n": XBlockI18nService, 'mako': mako_service, "settings": SettingsService(), @@ -220,7 +222,7 @@ def _prepare_runtime_for_preview(request, block): # Set up functions to modify the fragment produced by student_view block.runtime.wrappers = wrappers block.runtime.wrappers_asides = wrappers_asides - block.runtime._services.update(services) # pylint: disable=protected-access + block.runtime._runtime_services.update(services) # lint-amnesty, pylint: disable=protected-access # xmodules can check for this attribute during rendering to determine if # they are being rendered for preview (i.e. in Studio) @@ -264,7 +266,9 @@ def _load_preview_block(request: Request, block: XModuleMixin): else: wrapper = partial(LmsFieldData, student_data=student_data) - _prepare_runtime_for_preview(request, block) + # wrap the _field_data upfront to pass to _prepare_runtime_for_preview + wrapped_field_data = wrapper(block._field_data) # pylint: disable=protected-access + _prepare_runtime_for_preview(request, block, wrapped_field_data) block.bind_for_student( request.user.id, @@ -319,7 +323,7 @@ def _studio_wrap_xblock(xblock, view, frag, context, display_name_only=False): 'language': getattr(course, 'language', None) } - add_webpack_to_fragment(frag, "js/factories/xblock_validation") + add_webpack_js_to_fragment(frag, "js/factories/xblock_validation") html = render_to_string('studio_xblock_wrapper.html', template_context) frag = wrap_fragment(frag, html) diff --git a/cms/djangoapps/contentstore/views/tabs.py b/cms/djangoapps/contentstore/views/tabs.py index 4018b11883..20426933f3 100644 --- a/cms/djangoapps/contentstore/views/tabs.py +++ b/cms/djangoapps/contentstore/views/tabs.py @@ -7,6 +7,7 @@ from django.contrib.auth import get_user_model from django.contrib.auth.decorators import login_required from django.core.exceptions import PermissionDenied from django.http import HttpResponseNotFound +from django.shortcuts import redirect from django.views.decorators.csrf import ensure_csrf_cookie from django.views.decorators.http import require_http_methods from opaque_keys.edx.keys import CourseKey, UsageKey @@ -19,7 +20,8 @@ from xmodule.tabs import CourseTab, CourseTabList, InvalidTabsException, StaticT from common.djangoapps.edxmako.shortcuts import render_to_response from common.djangoapps.student.auth import has_course_author_access from common.djangoapps.util.json_request import JsonResponse, JsonResponseBadRequest, expect_json -from ..utils import get_lms_link_for_item, get_pages_and_resources_url +from ..toggles import use_new_custom_pages +from ..utils import get_lms_link_for_item, get_pages_and_resources_url, get_custom_pages_url __all__ = ["tabs_handler", "update_tabs_handler"] @@ -63,7 +65,8 @@ def tabs_handler(request, course_key_string): elif request.method == "GET": # assume html # get all tabs from the tabs list: static tabs (a.k.a. user-created tabs) and built-in tabs # present in the same order they are displayed in LMS - + if use_new_custom_pages(course_key): + return redirect(get_custom_pages_url(course_key)) tabs_to_render = list(get_course_tabs(course_item, request.user)) return render_to_response( diff --git a/cms/djangoapps/contentstore/views/tests/test_block.py b/cms/djangoapps/contentstore/views/tests/test_block.py index 5199553b2b..a25d0670b4 100644 --- a/cms/djangoapps/contentstore/views/tests/test_block.py +++ b/cms/djangoapps/contentstore/views/tests/test_block.py @@ -62,7 +62,7 @@ from cms.djangoapps.contentstore.utils import ( duplicate_block, update_from_source, ) -from cms.djangoapps.contentstore.xblock_services import xblock_service as item_module +from cms.djangoapps.contentstore.xblock_storage_handlers import view_handlers as item_module from common.djangoapps.student.tests.factories import StaffFactory, UserFactory from common.djangoapps.xblock_django.models import ( XBlockConfiguration, @@ -74,7 +74,7 @@ from lms.djangoapps.lms_xblock.mixin import NONSENSICAL_ACCESS_RESTRICTION from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration from ..component import component_handler, get_component_templates -from cms.djangoapps.contentstore.xblock_services.xblock_service import ( +from cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers import ( ALWAYS, VisibilityState, get_block_info, @@ -1509,7 +1509,7 @@ class TestMoveItem(ItemTest): validation = html.validate() self.assertEqual(len(validation.messages), 0) - @patch("cms.djangoapps.contentstore.xblock_services.xblock_service.log") + @patch("cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers.log") def test_move_logging(self, mock_logger): """ Test logging when an item is successfully moved. diff --git a/cms/djangoapps/contentstore/views/tests/test_course_index.py b/cms/djangoapps/contentstore/views/tests/test_course_index.py index 3fb7130592..1751ec42b8 100644 --- a/cms/djangoapps/contentstore/views/tests/test_course_index.py +++ b/cms/djangoapps/contentstore/views/tests/test_course_index.py @@ -39,7 +39,7 @@ from xmodule.modulestore.tests.django_utils import TEST_DATA_SPLIT_MODULESTORE from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory, LibraryFactory, check_mongo_calls # lint-amnesty, pylint: disable=wrong-import-order from ..course import _deprecated_blocks_info, course_outline_initial_state, reindex_course_and_check_access -from cms.djangoapps.contentstore.xblock_services.xblock_service import VisibilityState, create_xblock_info +from cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers import VisibilityState, create_xblock_info class TestCourseIndex(CourseTestCase): diff --git a/cms/djangoapps/contentstore/views/tests/test_entrance_exam.py b/cms/djangoapps/contentstore/views/tests/test_entrance_exam.py index 8e9d52d494..a2cb570cf7 100644 --- a/cms/djangoapps/contentstore/views/tests/test_entrance_exam.py +++ b/cms/djangoapps/contentstore/views/tests/test_entrance_exam.py @@ -27,7 +27,7 @@ from ..entrance_exam import ( update_entrance_exam ) from cms.djangoapps.contentstore.helpers import GRADER_TYPES -from cms.djangoapps.contentstore.xblock_services.create_xblock import create_xblock +from cms.djangoapps.contentstore.xblock_storage_handlers.create_xblock import create_xblock @patch.dict(settings.FEATURES, {'ENTRANCE_EXAMS': True}) diff --git a/cms/djangoapps/contentstore/views/tests/test_gating.py b/cms/djangoapps/contentstore/views/tests/test_gating.py index 94a073c2d2..73febb91e6 100644 --- a/cms/djangoapps/contentstore/views/tests/test_gating.py +++ b/cms/djangoapps/contentstore/views/tests/test_gating.py @@ -14,7 +14,7 @@ from cms.djangoapps.contentstore.tests.utils import CourseTestCase from cms.djangoapps.contentstore.utils import reverse_usage_url from openedx.core.lib.gating.api import GATING_NAMESPACE_QUALIFIER -from cms.djangoapps.contentstore.xblock_services.xblock_service import VisibilityState +from cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers import VisibilityState @ddt.ddt @@ -57,7 +57,7 @@ class TestSubsectionGating(CourseTestCase): ) self.seq2_url = reverse_usage_url('xblock_handler', self.seq2.location) - @patch('cms.djangoapps.contentstore.xblock_services.xblock_service.gating_api.add_prerequisite') + @patch('cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers.gating_api.add_prerequisite') def test_add_prerequisite(self, mock_add_prereq): """ Test adding a subsection as a prerequisite @@ -69,7 +69,7 @@ class TestSubsectionGating(CourseTestCase): ) mock_add_prereq.assert_called_with(self.course.id, self.seq1.location) - @patch('cms.djangoapps.contentstore.xblock_services.xblock_service.gating_api.remove_prerequisite') + @patch('cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers.gating_api.remove_prerequisite') def test_remove_prerequisite(self, mock_remove_prereq): """ Test removing a subsection as a prerequisite @@ -81,7 +81,7 @@ class TestSubsectionGating(CourseTestCase): ) mock_remove_prereq.assert_called_with(self.seq1.location) - @patch('cms.djangoapps.contentstore.xblock_services.xblock_service.gating_api.set_required_content') + @patch('cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers.gating_api.set_required_content') def test_add_gate(self, mock_set_required_content): """ Test adding a gated subsection @@ -100,7 +100,7 @@ class TestSubsectionGating(CourseTestCase): '100' ) - @patch('cms.djangoapps.contentstore.xblock_services.xblock_service.gating_api.set_required_content') + @patch('cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers.gating_api.set_required_content') def test_remove_gate(self, mock_set_required_content): """ Test removing a gated subsection @@ -118,9 +118,9 @@ class TestSubsectionGating(CourseTestCase): '' ) - @patch('cms.djangoapps.contentstore.xblock_services.xblock_service.gating_api.get_prerequisites') - @patch('cms.djangoapps.contentstore.xblock_services.xblock_service.gating_api.get_required_content') - @patch('cms.djangoapps.contentstore.xblock_services.xblock_service.gating_api.is_prerequisite') + @patch('cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers.gating_api.get_prerequisites') + @patch('cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers.gating_api.get_required_content') + @patch('cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers.gating_api.is_prerequisite') @ddt.data( (90, None), (None, 90), diff --git a/cms/djangoapps/contentstore/views/tests/test_preview.py b/cms/djangoapps/contentstore/views/tests/test_preview.py index 0f6786ca62..0908a2eb8f 100644 --- a/cms/djangoapps/contentstore/views/tests/test_preview.py +++ b/cms/djangoapps/contentstore/views/tests/test_preview.py @@ -172,6 +172,7 @@ class GetPreviewHtmlTestCase(ModuleStoreTestCase): self.assertFalse(modulestore().has_changes(modulestore().get_item(block.location))) +@XBlock.needs("field-data") @XBlock.needs("i18n") @XBlock.needs("mako") @XBlock.needs("replace_urls") @@ -203,6 +204,7 @@ class StudioXBlockServiceBindingTest(ModuleStoreTestCase): self.user = UserFactory() self.course = CourseFactory.create() self.request = mock.Mock() + self.field_data = mock.Mock() @XBlock.register_temp_plugin(PureXBlock, identifier='pure') @ddt.data("user", "i18n", "field-data", "teams_configuration", "replace_urls") @@ -211,7 +213,11 @@ class StudioXBlockServiceBindingTest(ModuleStoreTestCase): Tests that the 'user' and 'i18n' services are provided by the Studio runtime. """ block = BlockFactory(category="pure", parent=self.course) - _prepare_runtime_for_preview(self.request, block) + _prepare_runtime_for_preview( + self.request, + block, + self.field_data, + ) service = block.runtime.service(block, expected_service) self.assertIsNotNone(service) @@ -235,9 +241,14 @@ class CmsModuleSystemShimTest(ModuleStoreTestCase): self.request = RequestFactory().get('/dummy-url') self.request.user = self.user self.request.session = {} + self.field_data = mock.Mock() self.contentstore = contentstore() self.block = BlockFactory(category="problem", parent=course) - _prepare_runtime_for_preview(self.request, block=self.block) + _prepare_runtime_for_preview( + self.request, + block=self.block, + field_data=mock.Mock(), + ) self.course = self.store.get_item(course.location) def test_get_user_role(self): @@ -292,7 +303,11 @@ class CmsModuleSystemShimTest(ModuleStoreTestCase): """Test anonymous_user_id on a block which uses per-student anonymous IDs""" # Create the runtime with the flag turned on. block = BlockFactory(category="problem", parent=self.course) - _prepare_runtime_for_preview(self.request, block=block) + _prepare_runtime_for_preview( + self.request, + block=block, + field_data=mock.Mock(), + ) deprecated_anonymous_user_id = ( block.runtime.service(block, 'user').get_current_user().opt_attrs.get(ATTR_KEY_DEPRECATED_ANONYMOUS_USER_ID) ) @@ -303,7 +318,11 @@ class CmsModuleSystemShimTest(ModuleStoreTestCase): """Test anonymous_user_id on a block which uses per-course anonymous IDs""" # Create the runtime with the flag turned on. block = BlockFactory(category="lti", parent=self.course) - _prepare_runtime_for_preview(self.request, block=block) + _prepare_runtime_for_preview( + self.request, + block=block, + field_data=mock.Mock(), + ) anonymous_user_id = ( block.runtime.service(block, 'user').get_current_user().opt_attrs.get(ATTR_KEY_ANONYMOUS_USER_ID) diff --git a/cms/djangoapps/contentstore/views/user.py b/cms/djangoapps/contentstore/views/user.py index 42ccb6384c..d97c8c24fa 100644 --- a/cms/djangoapps/contentstore/views/user.py +++ b/cms/djangoapps/contentstore/views/user.py @@ -5,6 +5,7 @@ from django.contrib.auth.decorators import login_required from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user from django.core.exceptions import PermissionDenied from django.http import HttpResponseNotFound +from django.shortcuts import redirect from django.utils.translation import gettext as _ from django.views.decorators.csrf import ensure_csrf_cookie from django.views.decorators.http import require_http_methods, require_POST @@ -20,6 +21,9 @@ from common.djangoapps.student.roles import CourseInstructorRole, CourseStaffRol from common.djangoapps.util.json_request import JsonResponse, expect_json from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order +from ..toggles import use_new_course_team_page +from ..utils import get_course_team_url + __all__ = ['request_course_creator', 'course_team_handler'] @@ -55,6 +59,8 @@ def course_team_handler(request, course_key_string=None, email=None): if 'application/json' in request.META.get('HTTP_ACCEPT', 'application/json'): return _course_team_user(request, course_key, email) elif request.method == 'GET': # assume html + if use_new_course_team_page(course_key): + return redirect(get_course_team_url(course_key)) return _manage_users(request, course_key) else: return HttpResponseNotFound() diff --git a/cms/djangoapps/contentstore/views/videos.py b/cms/djangoapps/contentstore/views/videos.py index 1410faa7a7..7627c1e3f5 100644 --- a/cms/djangoapps/contentstore/views/videos.py +++ b/cms/djangoapps/contentstore/views/videos.py @@ -17,6 +17,7 @@ from django.conf import settings from django.contrib.auth.decorators import login_required from django.contrib.staticfiles.storage import staticfiles_storage from django.http import FileResponse, HttpResponseNotFound +from django.shortcuts import redirect from django.urls import reverse from django.utils.translation import gettext as _ from django.utils.translation import gettext_noop @@ -57,7 +58,8 @@ from openedx.core.lib.api.view_utils import view_auth_classes from xmodule.video_block.transcripts_utils import Transcript # lint-amnesty, pylint: disable=wrong-import-order from ..models import VideoUploadConfig -from ..utils import reverse_course_url +from ..toggles import use_new_video_uploads_page +from ..utils import reverse_course_url, get_video_uploads_url from ..video_utils import validate_video_image from .course import get_course_and_check_access @@ -700,7 +702,8 @@ def videos_index_html(course, pagination_conf=None): context['active_transcript_preferences'] = get_transcript_preferences(str(course.id)) # Cached state for transcript providers' credentials (org-specific) context['transcript_credentials'] = get_transcript_credentials_state_for_org(course.id.org) - + if use_new_video_uploads_page(course.id): + return redirect(get_video_uploads_url(course.id)) return render_to_response('videos_index.html', context) diff --git a/cms/djangoapps/contentstore/xblock_services/__init__.py b/cms/djangoapps/contentstore/xblock_services/__init__.py deleted file mode 100644 index 70ff825691..0000000000 --- a/cms/djangoapps/contentstore/xblock_services/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -""" -Xblock services that contain the business logic for xblock views. -""" -from .create_xblock import * -from .xblock_helpers import * -from .xblock_service import * diff --git a/cms/djangoapps/contentstore/xblock_storage_handlers/__init__.py b/cms/djangoapps/contentstore/xblock_storage_handlers/__init__.py new file mode 100644 index 0000000000..bf5d9adb35 --- /dev/null +++ b/cms/djangoapps/contentstore/xblock_storage_handlers/__init__.py @@ -0,0 +1,9 @@ +""" +The xblock_storage_handlers folder contains service methods that implement the business logic for view endpoints +located in contentstore/views/block.py. It is renamed to xblock_storage_handlers to reflect its responsibility +of handling storage-related operations of xblocks, such as creation, retrieval, and deletion. + +The view_handlers.py file includes business methods called by the view endpoints. +These methods, such as handle_xblock, delete_orphans, etc., interact with the required modulestore methods, +handle any errors, and aggregate and serialize data for the response. +""" diff --git a/cms/djangoapps/contentstore/xblock_services/create_xblock.py b/cms/djangoapps/contentstore/xblock_storage_handlers/create_xblock.py similarity index 100% rename from cms/djangoapps/contentstore/xblock_services/create_xblock.py rename to cms/djangoapps/contentstore/xblock_storage_handlers/create_xblock.py diff --git a/cms/djangoapps/contentstore/xblock_services/xblock_service.py b/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py similarity index 99% rename from cms/djangoapps/contentstore/xblock_services/xblock_service.py rename to cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py index f1fb8eb048..b2b96e2427 100644 --- a/cms/djangoapps/contentstore/xblock_services/xblock_service.py +++ b/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py @@ -116,15 +116,6 @@ CREATE_IF_NOT_FOUND = ["course_info"] NEVER = lambda x: False ALWAYS = lambda x: True -__all__ = [ - "handle_xblock", - "create_xblock_info", - "load_services_for_studio", - "get_block_info", - "get_xblock", - "delete_orphans", -] - def _filter_entrance_exam_grader(graders): """ diff --git a/cms/djangoapps/contentstore/xblock_services/xblock_helpers.py b/cms/djangoapps/contentstore/xblock_storage_handlers/xblock_helpers.py similarity index 100% rename from cms/djangoapps/contentstore/xblock_services/xblock_helpers.py rename to cms/djangoapps/contentstore/xblock_storage_handlers/xblock_helpers.py diff --git a/cms/djangoapps/pipeline_js/js/xmodule.js b/cms/djangoapps/pipeline_js/js/xmodule.js index 6c44da31f1..96f8f1afa9 100644 --- a/cms/djangoapps/pipeline_js/js/xmodule.js +++ b/cms/djangoapps/pipeline_js/js/xmodule.js @@ -32,7 +32,10 @@ define( ['\\[', '\\]'], ['[mathjax]', '[/mathjax]'] ] - } + }, + CommonHTML: { linebreaks: { automatic: true } }, + SVG: { linebreaks: { automatic: true } }, + "HTML-CSS": { linebreaks: { automatic: true } }, }); // In order to eliminate all flashing during interactive @@ -42,6 +45,25 @@ define( // the fast preview setting as shown in the context menu. window.MathJax.Hub.processSectionDelay = 0; window.MathJax.Hub.Configured(); + + window.addEventListener('resize', MJrenderer); + + let t = -1; + let delay = 1000; + let oldWidth = document.documentElement.scrollWidth; + function MJrenderer() { + // don't rerender if the window is the same size as before + if (t >= 0) { + window.clearTimeout(t); + } + if (oldWidth !== document.documentElement.scrollWidth) { + t = window.setTimeout(function() { + oldWidth = document.documentElement.scrollWidth; + MathJax.Hub.Queue(["Rerender", MathJax.Hub]); + t = -1; + }, delay); + } + }; } ); window.CodeMirror = CodeMirror; diff --git a/cms/envs/common.py b/cms/envs/common.py index 4d8d8edfcf..1e4914af94 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -1437,7 +1437,6 @@ WEBPACK_LOADER = { 'DEFAULT': { 'BUNDLE_DIR_NAME': 'bundles/', 'STATS_FILE': os.path.join(STATIC_ROOT, 'webpack-stats.json'), - 'LOADER_CLASS': 'xmodule.util.xmodule_django.XModuleWebpackLoader', }, 'WORKERS': { 'BUNDLE_DIR_NAME': 'bundles/', @@ -2704,6 +2703,7 @@ TEXTBOOKS_HELP_URL = "https://edx.readthedocs.io/projects/open-edx-building-and- WIKI_HELP_URL = "https://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/course_assets/course_wiki.html" CUSTOM_PAGES_HELP_URL = "https://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/course_assets/pages.html#adding-custom-pages" COURSE_LIVE_HELP_URL = "https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/course_assets/course_live.html" +ORA_SETTINGS_HELP_URL = "https://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/course_assets/ora_settings.html" # keys for big blue button live provider COURSE_LIVE_GLOBAL_CREDENTIALS = {} @@ -2726,11 +2726,3 @@ BRAZE_COURSE_ENROLLMENT_CANVAS_ID = '' DISCUSSIONS_INCONTEXT_FEEDBACK_URL = '' DISCUSSIONS_INCONTEXT_LEARNMORE_URL = '' - -OPEN_EDX_FILTERS_CONFIG = { - "org.openedx.content_authoring.staged_content.static_filter_source.v1": { - "pipeline": [ - "openedx.core.djangoapps.content_staging.filters.IgnoreLargeFiles", - ] - } -} diff --git a/cms/envs/devstack-experimental.yml b/cms/envs/devstack-experimental.yml index 46e6af3894..9203ddd31c 100644 --- a/cms/envs/devstack-experimental.yml +++ b/cms/envs/devstack-experimental.yml @@ -326,7 +326,6 @@ JWT_AUTH: JWT_AUDIENCE: lms-key JWT_AUTH_COOKIE_HEADER_PAYLOAD: edx-jwt-cookie-header-payload JWT_AUTH_COOKIE_SIGNATURE: edx-jwt-cookie-signature - JWT_AUTH_REFRESH_COOKIE: edx-jwt-refresh-cookie JWT_ISSUER: http://edx.devstack.lms:18000/oauth2 JWT_ISSUERS: - AUDIENCE: lms-key diff --git a/cms/envs/devstack.py b/cms/envs/devstack.py index 78313ea720..b1397ed841 100644 --- a/cms/envs/devstack.py +++ b/cms/envs/devstack.py @@ -49,6 +49,11 @@ LMS_ROOT_URL = f'http://{LMS_BASE}' FEATURES['PREVIEW_LMS_BASE'] = "preview." + LMS_BASE FRONTEND_REGISTER_URL = LMS_ROOT_URL + '/register' + +################################## Video Pipeline Settings ######################### + +FEATURES['ENABLE_VIDEO_UPLOAD_PIPELINE'] = True + ########################### PIPELINE ################################# # Skip packaging and optimization in development @@ -99,7 +104,6 @@ DEBUG_TOOLBAR_PANELS = ( 'debug_toolbar.panels.request.RequestPanel', 'debug_toolbar.panels.sql.SQLPanel', 'debug_toolbar.panels.signals.SignalsPanel', - 'debug_toolbar.panels.logging.LoggingPanel', 'debug_toolbar.panels.profiling.ProfilingPanel', 'debug_toolbar.panels.history.HistoryPanel', ) @@ -300,6 +304,7 @@ EVENT_BUS_PRODUCER = 'edx_event_bus_redis.create_producer' EVENT_BUS_REDIS_CONNECTION_URL = 'redis://:password@edx.devstack.redis:6379/' EVENT_BUS_TOPIC_PREFIX = 'dev' EVENT_BUS_CONSUMER = 'edx_event_bus_redis.RedisEventConsumer' +EVENT_BUS_XBLOCK_LIFECYCLE_TOPIC = 'course-authoring-xblock-lifecycle' ################# New settings must go ABOVE this line ################# ######################################################################## diff --git a/cms/static/js/i18n/es-419/djangojs.js b/cms/static/js/i18n/es-419/djangojs.js index dc15ee2682..6cae6fc9b6 100644 --- a/cms/static/js/i18n/es-419/djangojs.js +++ b/cms/static/js/i18n/es-419/djangojs.js @@ -601,6 +601,7 @@ "Copy Email To Editor": "Copiar el correo al editor", "Copy Exam Code": "Copia el C\u00f3digo de el Examen", "Copy row": "Copiar la fila", + "Copying": "Copiando", "Correct failed component": "Corregir componente fallido", "Cost": "Costo", "Could not find Certificate Exception in the allowlist. Please refresh the page and try again": "No se pudo hallar Invalidaci\u00f3n del Certificado en la lista. Por favor, actualiza la p\u00e1gina e intenta nuevamente.", @@ -1155,6 +1156,7 @@ "Last published {lastPublishedStart}{publishedOn}{lastPublishedEnd} by {publishedByStart}{publishedBy}{publishedByEnd}": "Last published {lastPublishedStart}{publishedOn}{lastPublishedEnd} por {publishedByStart}{publishedBy}{publishedByEnd}", "Last updated": "\u00daltima actualizaci\u00f3n", "Learn More": "Aprender m\u00e1s", + "Learn more": "Aprender m\u00e1s", "Learn more about {license_name}": "Saber m\u00e1s sobre {license_name}", "Learners are added to this cohort automatically.": "Los estudiantes son agregados autom\u00e1ticamente a esta cohorte.", "Learners are added to this cohort only when you provide their email addresses or usernames on this page.": "Los estudiantes son agregados a esta cohorte solamente cuando se incluye la direcci\u00f3n de correo electr\u00f3nico o el nombre de usuario en esta p\u00e1gina.", @@ -1285,6 +1287,7 @@ "New Password": "Nueva Contrase\u00f1a", "New document": "Documento nuevo", "New enrollment mode:": "Nuevo modo de inscripcion:", + "New files were added to this course's Files & Uploads": "Se agregaron nuevos archivos a la secci\u00f3n Archivos y Cargas de este curso", "New window": "Nueva ventana", "New {component_type}": "Nuevo {component_type}", "Next": "Siguiente", @@ -1408,6 +1411,7 @@ "Paste row after": "Pegar fila despu\u00e9s", "Paste row before": "Pegar fila antes", "Paste your embed code below:": "Pegar el c\u00f3digo para incrustar debajo:", + "Pasting": "Pegando", "Path to Signature Image": "Ruta a la imagen de la firma", "Pause": "Pausar", "Pay {subscriptionPrice} after {trialLength}-day free trial": "Paga {subscriptionPrice} cuando termine la prueba gratuita de {trialLength} d\u00edas", @@ -1755,6 +1759,7 @@ "Skip": "Omitir", "Social Media Links": "Enlaces de redes sociales", "Some Rights Reserved": "Algunos Derechos Reservados", + "Some errors occurred": "Se produjeron algunos errores", "Some images in this post have been omitted": "Algunas im\u00e1genes en esta publicaci\u00f3n han sido omitidas", "Something went wrong changing this enrollment. Please try again.": "Ocurri\u00f3 un error al cambiar esta inscripci\u00f3n. Por favor intenta nuevamente.", "Something went wrong. Please try again later.": "Algo sali\u00f3 mal. Por favor intente de nuevo m\u00e1s tarde.", @@ -1926,9 +1931,12 @@ "The following email addresses and/or usernames are invalid:": "El correo electr\u00f3nico y/o el nombre de usuario no son v\u00e1lidos:", "The following errors were generated:": "Se generaron los siguientes errores:", "The following file types are not allowed: ": "Los siguientes tipos de archivos son soportados:", + "The following files already exist in this course but don't match the version used by the component you pasted:": "Los siguientes archivos ya existen en este curso, pero no coinciden con la versi\u00f3n utilizada por el componente que peg\u00f3:", "The following information is already a part of your {platform} profile. We've included it here for your application.": "La siguiente informaci\u00f3n ya es parte de su perfil en {platform} . La hemos incluido aqu\u00ed para su aplicaci\u00f3n", "The following message will be displayed at the bottom of the courseware pages within your course:": "El siguiente mensaje ser\u00e1 mostrado al final de las p\u00e1ginas de los cursos. ", "The following options are available for the {license_name} license.": "Las siguientes opciones est\u00e1n disponibles para {license_name} licencia", + "The following required files could not be added to the course:": "Los siguientes archivos obligatorios no se pudieron agregar al curso:", + "The following required files were imported to this course:": "Los siguientes archivos obligatorios no se pudieron agregar al curso:", "The following users are no longer enrolled in the course:": "Los siguientes usuarios ya no est\u00e1n inscritos en el curso:", "The following warnings were generated:": "Se generaron las siguientes advertencias:", "The general category for this type of assignment, for example, Homework or Midterm Exam. This name is visible to learners.": "La categor\u00eda general para este tipo de asignaci\u00f3n, por ejemplo, Tareas o Examen trimestral. Este nombre es visible a los estudiantes.", @@ -1945,6 +1953,8 @@ "The name that is used for ID verification and that appears on your certificates.": "El nombre que es usado para la verificaci\u00f3n de identidad y aparece en sus certificados.", "The number of assignments of this type that will be dropped. The lowest scoring assignments are dropped first.": "El n\u00famero de asignaciones de este tipo que ser\u00e1n descartados. Las asignaciones con calificaciones m\u00e1s bajas ser\u00e1n las primeras en ser descartadas.", "The number of subsections in the course that contain problems of this assignment type.": "El n\u00famero de subdivisiones del curso que contiene problemas de este tipo de asignaci\u00f3n.", + "The number of {type} assignments defined here does not match the current number of {type} assignments in the course:": "El n\u00famero de tareas {type} definido aqu\u00ed no coincide con el n\u00famero actual de tareas {type} en el curso:", + "The number of {type} assignments in the course matches the number defined here.": "El n\u00famero de asignaciones {type} en el curso coincide con el n\u00famero definido aqu\u00ed.", "The onboarding service is temporarily unavailable. Please try again later.": "El servicio de inducci\u00f3n se temporalmente fuera de servicio. Por favor intenta de nuevo.", "The organization that this signatory belongs to, as it should appear on certificates.": "La organizaci\u00f3n a la que pertenece el firmante, como debe aparecer en los certificados. ", "The page \"{route}\" could not be found.": "La p\u00e1gina \"{route}\" no pudo ser encontrada.", @@ -1967,6 +1977,7 @@ "The {cohortGroupName} cohort has been created. You can manually add students to this cohort below.": "El cohorte {cohortGroupName} ha sido creado. Puedes manualmente a\u00f1adir estudiantes a este cohorte.", "There are currently {stuck_learners} learners in the waiting state, meaning they have not yet met all requirements for Peer Assessment. ": "Actualmente se encuentran {stuck_learners} estudiantes en el estado de espera, lo cual significa que ellos a\u00fan no cumplen con todos los requerimientos para el examen en parejas. ", "There are invalid keywords in your email. Check the following keywords and try again.": "Hay palabras clave inv\u00e1lidas en tu correo. Por favor, comprueba las siguientes claves e int\u00e9ntalo de nuevo:", + "There are no assignments of this type in the course.": "No hay tareas de este tipo en el curso.", "There are no posts in this topic yet.": "Todav\u00eda no hay publicaciones en este tema.", "There has been a failure to export to XML at least one component. It is recommended that you go to the edit page and repair the error before attempting another export. Please check that all components on the page are valid and do not display any error messages.": "Ha habido una falla para exportar al XML al menos un componente. Se recomienda ir a la p\u00e1gina de edici\u00f3n y reparar el error antes de intentar otra exportaci\u00f3n. Por favor, verifique que todos los componentes en la p\u00e1gina son validos y no exhiben ninguna mensaje de error. ", "There has been an error processing your survey.": "Ocurri\u00f3 un error al procesar tu encuesta.", @@ -2268,6 +2279,7 @@ "Very low": "Muy bajo", "Video Capture Error": "Error en la captura de v\u00eddeo", "Video ID": "ID del video", + "Video Sharing": "Compartir videos", "Video Source Language": "Idioma de la fuente del video", "Video Status": "Estado del v\u00eddeo", "Video duration is {humanizeDuration}": "La duraci\u00f3n del v\u00eddeo es {humanizeDuration}", @@ -2309,6 +2321,7 @@ "Waiting": "Esperando", "Want to make edX better for everyone?": "\u00bfQuieres hacer que edX sea mejor para todos?", "Warning": "Advertencia", + "Warning: ": "Advertencia:", "Warnings": "Advertencias", "We ask you to activate your account to ensure it is really you creating the account and to prevent fraud.": "Necesitamos que active su cuenta para asegurarnos que es usted realmente el que est\u00e1 creando la cuenta y para prevenir fraude.", "We couldn't create your account.": "No pudimos crear tu cuenta.", @@ -2419,8 +2432,10 @@ "You may also lose access to verified certificates and other program credentials like MicroMasters certificates. If you want to make a copy of these for your records before proceeding with deletion, follow the instructions for {htmlStart}printing or downloading a certificate{htmlEnd}.": "Puede que tambi\u00e9n pierdas el acceso a los certificados verificados y otros certificados de programas como los de los MicroMasters. Si quieres hacer una copia de dichos certificados para tus archivos antes de proceder a la eliminaci\u00f3n, sigue las instrucciones para {htmlStart}imprimir o descargar un certificado{htmlEnd}.", "You may be able to complete the image capture procedure without assistance, but it may take a couple of submission attempts to get the camera positioning right. Optimal camera positioning varies with each computer, but generally the best position for a headshot is approximately 12-18 inches (30-45 centimeters) from the camera, with your head centered relative to the computer screen. ": "Es posible que pueda completar el procedimiento de captura de im\u00e1genes sin ayuda, pero puede tomar un par de intentos de env\u00edo para obtener la posici\u00f3n correcta de la c\u00e1mara. El posicionamiento \u00f3ptimo de la c\u00e1mara var\u00eda con cada computadora, pero generalmente la mejor posici\u00f3n para la doma de la cabeza es de aproximadamente 12-18 pulgadas (30-45 cent\u00edmetros) de la c\u00e1mara, con la cabeza centrada en relaci\u00f3n con la pantalla de la computadora.", "You may be able to complete the image capture procedure without assistance, but it may take a couple of submission attempts to get the camera positioning right. Optimal camera positioning varies with each computer, but generally, the best position for a photo of an ID card is 8-12 inches (20-30 centimeters) from the camera, with the ID card centered relative to the camera. ": "Es posible que pueda completar el procedimiento de captura de im\u00e1genes sin ayuda, pero puede tomar un par de intentos de env\u00edo para obtener la posici\u00f3n correcta de la c\u00e1mara. El posicionamiento \u00f3ptimo de la c\u00e1mara var\u00eda con cada computadora, pero en general, la mejor posici\u00f3n para una foto de una tarjeta de identificaci\u00f3n es de 8 a 12 pulgadas (20-30 cent\u00edmetros) de la c\u00e1mara, con la tarjeta de identificaci\u00f3n centrada en relaci\u00f3n con la c\u00e1mara.", + "You may need to update a file(s) manually": "Es posible que deba actualizar un archivo (s) manualmente", "You must be over 13 to share a full profile. If you are over 13, make sure that you have specified a birth year on the {account_settings_page_link}": "Debes tener 13 a\u00f1os o m\u00e1s para compartir un perfil completo. Si tienes m\u00e1s de esta edad, aseg\u00farate que has especificado un a\u00f1o de nacimiento en {account_settings_page_link}", "You must enter a valid email address in order to add a new team member": "Se debe introducir un email valido para adicionar un nuevo miembro en el equipo. ", + "You must have at least one undroppable <%- types %> assignment.": "Debes tener al menos una actividad no prescindible de <%- types %>.", "You must provide a learner name.": "Debe ingresar un nombre.", "You must select a session by {expiration_date} to access the course.": "Debes seleccionar una edici\u00f3n antes de {expiration_date} para acceder al curso.", "You must select a session to access the course.": "Debe seleccionar una sesi\u00f3n para acceder al curso.", @@ -2630,6 +2645,7 @@ "your course": "su curso", "{InstructionsSpanStart}{videoImageResoultion}{lineBreak} {videoImageSupportedFileFormats}{spanEnd}": "{InstructionsSpanStart}{videoImageResoultion}{lineBreak} {videoImageSupportedFileFormats}{spanEnd}", "{ReqTextSpanStart}Requirements{spanEnd}{lineBreak}{InstructionsSpanStart}{videoImageResoultion}{lineBreak} {videoImageSupportedFileFormats}{spanEnd}": "{ReqTextSpanStart}Requirements{spanEnd}{lineBreak}{InstructionsSpanStart}{videoImageResoultion}{lineBreak} {videoImageSupportedFileFormats}{spanEnd}", + "{assignment_count} {type} assignment(s) found:": "{assignment_count} {type} tarea(s) encontrada(s):", "{browse_span_start}Browse teams in other topics{span_end} or {search_span_start}search teams{span_end} in this topic. If you still can't find a team to join, {create_span_start}create a new team in this topic{span_end}.": "Intenta {browse_span_start}explorar equipos en otros temas{span_end} o {search_span_start}busca equipos{span_end} en este tema. S\u00ed a\u00fan no puedes encontrar un equipo para unirte, {create_span_start}crea un nuevo equipo en este tema{span_end}.", "{categoryText} in {parentDisplayname}": "{categoryText} en {parentDisplayname}", "{currentCountOpeningTag}{currentCharacterCount}{currentCountClosingTag} of {maxCharacters}": "{currentCountOpeningTag}{currentCharacterCount}{currentCountClosingTag} de {maxCharacters}", diff --git a/cms/static/js/views/xblock.js b/cms/static/js/views/xblock.js index 2330c9deb5..6b913d5239 100644 --- a/cms/static/js/views/xblock.js +++ b/cms/static/js/views/xblock.js @@ -23,9 +23,10 @@ function($, _, ViewUtils, BaseView, XBlock, HtmlUtils) { var self = this, view = this.view, xblockInfo = this.model, - xblockUrl = xblockInfo.url(); + xblockUrl = xblockInfo.url(), + querystring = window.location.search; // pass any querystring down to child views return $.ajax({ - url: decodeURIComponent(xblockUrl) + '/' + view, + url: decodeURIComponent(xblockUrl) + '/' + view + querystring, type: 'GET', cache: false, headers: {Accept: 'application/json'}, diff --git a/cms/templates/content_libraries/xblock_iframe.html b/cms/templates/content_libraries/xblock_iframe.html index 47966bc4cd..79d0de1409 100644 --- a/cms/templates/content_libraries/xblock_iframe.html +++ b/cms/templates/content_libraries/xblock_iframe.html @@ -81,6 +81,12 @@