diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 8373d63448ef..038f49cd4470 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -21,7 +21,11 @@ openedx/core/djangoapps/user_api/ @openedx/com openedx/core/djangoapps/user_authn/ @openedx/committers-edx-platform-2u-infinity openedx/core/djangoapps/verified_track_content/ @openedx/committers-edx-platform-2u-infinity openedx/features/course_experience/ -xmodule/ +# The Aximprovements team is working on extracting all built-in XBlocks +# to the external repository (xblocks-contrib). They need to be notified +# about any changes within xmodule to stay aligned with this effort. +# Ticket: https://github.com/openedx/edx-platform/issues/34827 +xmodule/ @farhan @irtazaakram @salman2013 # Core Extensions lms/djangoapps/discussion/ diff --git a/.github/workflows/check-consistent-dependencies.yml b/.github/workflows/check-consistent-dependencies.yml index c3f35d92a03b..46f801179e84 100644 --- a/.github/workflows/check-consistent-dependencies.yml +++ b/.github/workflows/check-consistent-dependencies.yml @@ -7,6 +7,7 @@ name: Consistent Python dependencies on: pull_request: + merge_group: defaults: run: @@ -18,26 +19,31 @@ jobs: runs-on: ubuntu-24.04 steps: + # Always checkout the code because we don't always have a PR url. + - uses: actions/checkout@v5 + # Only run remaining steps if there are changes to requirements/** + # We do this instead of using path based short-circuiting. + # see https://stackoverflow.com/questions/77996177/how-can-i-handle-a-required-check-that-isnt-always-triggered + # for some more details. - name: "Decide whether to short-circuit" - env: - GH_TOKEN: "${{ github.token }}" - PR_URL: "${{ github.event.pull_request.html_url }}" run: | - paths=$(gh pr diff "$PR_URL" --name-only) - echo $'Paths touched in PR:\n'"$paths" + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + BASE_SHA="${{ github.event.pull_request.base.sha }}" + else + BASE_SHA="${{ github.event.merge_group.base_sha }}" + fi + + # Fetch the base sha so we can compare to it. It's not checked out by + # default. + git fetch origin "$BASE_SHA" # The ^"? is because git may quote weird file paths - matched="$(echo "$paths" | grep -P '^"?((requirements/)|(scripts/.*?/requirements/))' || true)" - echo $'Relevant paths:\n'"$matched" - if [[ -n "$matched" ]]; then - echo "RELEVANT=true" >> "$GITHUB_ENV" + if git diff --name-only "$BASE_SHA" | grep -P '^"?((requirements/)|(scripts/.*?/requirements/))'; then + echo "RELEVANT=true" >> "$GITHUB_ENV" fi - - uses: actions/checkout@v5 - if: ${{ env.RELEVANT == 'true' }} - - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v6 if: ${{ env.RELEVANT == 'true' }} with: python-version: '3.11' diff --git a/.github/workflows/check-for-tutorial-prs.yml b/.github/workflows/check-for-tutorial-prs.yml index 1dc4d3860956..e3969a1920e9 100644 --- a/.github/workflows/check-for-tutorial-prs.yml +++ b/.github/workflows/check-for-tutorial-prs.yml @@ -26,7 +26,7 @@ jobs: uses: actions/checkout@v5 - name: Comment PR - uses: thollander/actions-comment-pull-request@v2 + uses: thollander/actions-comment-pull-request@v3 with: message: | Thank you for your pull request! Congratulations on completing the Open edX tutorial! A team member will be by to take a look shortly. diff --git a/.github/workflows/check_python_dependencies.yml b/.github/workflows/check_python_dependencies.yml index 7b93a545cd4b..f215880f0ef4 100644 --- a/.github/workflows/check_python_dependencies.yml +++ b/.github/workflows/check_python_dependencies.yml @@ -2,6 +2,7 @@ name: Check Python Dependencies on: pull_request: + merge_group: jobs: check_dependencies: @@ -16,7 +17,7 @@ jobs: uses: actions/checkout@v5 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} diff --git a/.github/workflows/ci-static-analysis.yml b/.github/workflows/ci-static-analysis.yml index d2513ba2104f..deb2853899f4 100644 --- a/.github/workflows/ci-static-analysis.yml +++ b/.github/workflows/ci-static-analysis.yml @@ -1,6 +1,8 @@ name: Static analysis -on: pull_request +on: + pull_request: + merge_group: jobs: tests: @@ -15,7 +17,7 @@ jobs: steps: - uses: actions/checkout@v5 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} diff --git a/.github/workflows/commitlint.yml b/.github/workflows/commitlint.yml index fec11d6c259b..03b0c6c1336e 100644 --- a/.github/workflows/commitlint.yml +++ b/.github/workflows/commitlint.yml @@ -3,7 +3,8 @@ name: Lint Commit Messages on: - - pull_request + pull_request: + merge_group: jobs: commitlint: diff --git a/.github/workflows/compile-python-requirements.yml b/.github/workflows/compile-python-requirements.yml index 8673cc3c234c..9b4eb7f79753 100644 --- a/.github/workflows/compile-python-requirements.yml +++ b/.github/workflows/compile-python-requirements.yml @@ -24,7 +24,7 @@ jobs: ref: "${{ inputs.branch }}" - name: Set up Python environment - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.11" diff --git a/.github/workflows/js-tests.yml b/.github/workflows/js-tests.yml index 972435a820e3..1bb821daeb6d 100644 --- a/.github/workflows/js-tests.yml +++ b/.github/workflows/js-tests.yml @@ -2,6 +2,7 @@ name: Javascript tests on: pull_request: + merge_group: push: branches: - release-ulmo @@ -23,7 +24,7 @@ jobs: run: git fetch --depth=1 origin master - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v5 with: node-version: ${{ matrix.node-version }} cache: 'npm' @@ -43,7 +44,7 @@ jobs: run: sudo apt-get update && sudo apt-get install libxmlsec1-dev ubuntu-restricted-extras xvfb - name: Setup Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} diff --git a/.github/workflows/lint-imports.yml b/.github/workflows/lint-imports.yml index 17cc4ea0a935..ea6424807799 100644 --- a/.github/workflows/lint-imports.yml +++ b/.github/workflows/lint-imports.yml @@ -2,6 +2,7 @@ name: Lint Python Imports on: pull_request: + merge_group: push: branches: - release-ulmo @@ -16,7 +17,7 @@ jobs: uses: actions/checkout@v5 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.11" diff --git a/.github/workflows/lockfileversion-check.yml b/.github/workflows/lockfileversion-check.yml index ed0b7f97de6d..22baf1d80ab0 100644 --- a/.github/workflows/lockfileversion-check.yml +++ b/.github/workflows/lockfileversion-check.yml @@ -7,6 +7,7 @@ on: branches: - release-ulmo pull_request: + merge_group: jobs: version-check: diff --git a/.github/workflows/migrations-check.yml b/.github/workflows/migrations-check.yml index 686d8d9086b0..5da69916235f 100644 --- a/.github/workflows/migrations-check.yml +++ b/.github/workflows/migrations-check.yml @@ -3,6 +3,7 @@ name: Check Django Migrations on: workflow_dispatch: pull_request: + merge_group: push: branches: - release-ulmo @@ -73,7 +74,7 @@ jobs: uses: actions/checkout@v5 - name: Setup Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} diff --git a/.github/workflows/pylint-checks.yml b/.github/workflows/pylint-checks.yml index abc51eb91b74..1d6944cc6671 100644 --- a/.github/workflows/pylint-checks.yml +++ b/.github/workflows/pylint-checks.yml @@ -2,6 +2,7 @@ name: Pylint Checks on: pull_request: + merge_group: push: branches: - master @@ -37,7 +38,7 @@ jobs: run: sudo apt-get update && sudo apt-get install libxmlsec1-dev - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: 3.11 diff --git a/.github/workflows/quality-checks.yml b/.github/workflows/quality-checks.yml index ee67e3903569..dabb9c5c2cc6 100644 --- a/.github/workflows/quality-checks.yml +++ b/.github/workflows/quality-checks.yml @@ -2,6 +2,7 @@ name: Quality checks on: pull_request: + merge_group: push: branches: - release-ulmo @@ -29,12 +30,12 @@ jobs: run: sudo apt-get update && sudo apt-get install libxmlsec1-dev - name: Setup Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v5 with: node-version: ${{ matrix.node-version }} diff --git a/.github/workflows/semgrep.yml b/.github/workflows/semgrep.yml index d9d32ab9d36d..e0ab53dcd426 100644 --- a/.github/workflows/semgrep.yml +++ b/.github/workflows/semgrep.yml @@ -7,6 +7,7 @@ name: Semgrep code quality on: pull_request: + merge_group: push: branches: - release-ulmo @@ -26,7 +27,7 @@ jobs: with: fetch-depth: 1 - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v6 with: python-version: "${{ matrix.python-version }}" diff --git a/.github/workflows/shellcheck.yml b/.github/workflows/shellcheck.yml index a8df63a20d57..93bcb2378af9 100644 --- a/.github/workflows/shellcheck.yml +++ b/.github/workflows/shellcheck.yml @@ -7,6 +7,7 @@ name: ShellCheck on: pull_request: + merge_group: push: branches: - release-ulmo diff --git a/.github/workflows/static-assets-check.yml b/.github/workflows/static-assets-check.yml index 3a0afa76deb7..3cb66927e5f4 100644 --- a/.github/workflows/static-assets-check.yml +++ b/.github/workflows/static-assets-check.yml @@ -2,6 +2,7 @@ name: static assets check for lms and cms on: pull_request: + merge_group: push: branches: - release-ulmo @@ -38,7 +39,7 @@ jobs: uses: actions/checkout@v5 - name: Setup Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} @@ -48,7 +49,7 @@ jobs: sudo apt-get install libxmlsec1-dev pkg-config - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v5 with: node-version: ${{ matrix.node-version }} diff --git a/.github/workflows/unit-test-shards.json b/.github/workflows/unit-test-shards.json index 827366365fa8..cb9beeb3c6bf 100644 --- a/.github/workflows/unit-test-shards.json +++ b/.github/workflows/unit-test-shards.json @@ -239,7 +239,6 @@ "cms/djangoapps/course_creators/", "cms/djangoapps/export_course_metadata/", "cms/djangoapps/modulestore_migrator/", - "cms/djangoapps/maintenance/", "cms/djangoapps/models/", "cms/djangoapps/pipeline_js/", "cms/djangoapps/xblock_config/", diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 036d411fa1a0..ea0be609b9a4 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -1,7 +1,10 @@ name: unit-tests +permissions: + contents: read on: pull_request: + merge_group: push: branches: - release-ulmo @@ -22,7 +25,6 @@ jobs: - "3.11" django-version: - "pinned" - - "5.2" # When updating the shards, remember to make the same changes in # .github/workflows/unit-tests-gh-hosted.yml shard_name: @@ -95,7 +97,7 @@ jobs: docker ps - name: Setup Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} @@ -158,7 +160,7 @@ jobs: steps: - uses: actions/checkout@v5 - name: Setup Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: 3.11 @@ -288,7 +290,7 @@ jobs: uses: actions/checkout@v5 - name: Setup Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} diff --git a/.github/workflows/units-test-scripts-structures-pruning.yml b/.github/workflows/units-test-scripts-structures-pruning.yml index ef408cfe66ec..f2cb24301262 100644 --- a/.github/workflows/units-test-scripts-structures-pruning.yml +++ b/.github/workflows/units-test-scripts-structures-pruning.yml @@ -2,6 +2,7 @@ name: units-test-scripts-common on: pull_request: + merge_group: push: branches: - release-ulmo @@ -20,7 +21,7 @@ jobs: uses: actions/checkout@v5 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} diff --git a/.github/workflows/units-test-scripts-user-retirement.yml b/.github/workflows/units-test-scripts-user-retirement.yml index b43bbf46b0d4..70f78099da68 100644 --- a/.github/workflows/units-test-scripts-user-retirement.yml +++ b/.github/workflows/units-test-scripts-user-retirement.yml @@ -2,6 +2,7 @@ name: units-test-scripts-user-retirement on: pull_request: + merge_group: push: branches: - release-ulmo @@ -20,7 +21,7 @@ jobs: uses: actions/checkout@v5 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} diff --git a/.github/workflows/upgrade-one-python-dependency.yml b/.github/workflows/upgrade-one-python-dependency.yml index 1d8170961865..4ba459f3791e 100644 --- a/.github/workflows/upgrade-one-python-dependency.yml +++ b/.github/workflows/upgrade-one-python-dependency.yml @@ -37,7 +37,7 @@ jobs: ref: "${{ inputs.branch }}" - name: Set up Python environment - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "3.11" diff --git a/.github/workflows/verify-dunder-init.yml b/.github/workflows/verify-dunder-init.yml index 462f0e06af0b..f7517914c6e1 100644 --- a/.github/workflows/verify-dunder-init.yml +++ b/.github/workflows/verify-dunder-init.yml @@ -2,6 +2,8 @@ name: Verify Dunder __init__.py Files on: pull_request: + merge_group: + push: branches: - release-ulmo diff --git a/Makefile b/Makefile index 66c3608af038..7b5cf2643173 100644 --- a/Makefile +++ b/Makefile @@ -118,7 +118,7 @@ $(COMMON_CONSTRAINTS_TXT): printf "$(COMMON_CONSTRAINTS_TEMP_COMMENT)" | cat - $(@) > temp && mv temp $(@) compile-requirements: export CUSTOM_COMPILE_COMMAND=make upgrade -compile-requirements: pre-requirements $(COMMON_CONSTRAINTS_TXT) ## Re-compile *.in requirements to *.txt +compile-requirements: pre-requirements ## Re-compile *.in requirements to *.txt @# Bootstrapping: Rebuild pip and pip-tools first, and then install them @# so that if there are any failures we'll know now, rather than the next @# time someone tries to use the outputs. @@ -141,7 +141,7 @@ compile-requirements: pre-requirements $(COMMON_CONSTRAINTS_TXT) ## Re-compile * export REBUILD=''; \ done -upgrade: ## update the pip requirements files to use the latest releases satisfying our constraints +upgrade: $(COMMON_CONSTRAINTS_TXT) ## update the pip requirements files to use the latest releases satisfying our constraints $(MAKE) compile-requirements COMPILE_OPTS="--upgrade" upgrade-package: ## update just one package to the latest usable release diff --git a/cms/djangoapps/cms_user_tasks/signals.py b/cms/djangoapps/cms_user_tasks/signals.py index 40bfd5781825..e6ddba747d5f 100644 --- a/cms/djangoapps/cms_user_tasks/signals.py +++ b/cms/djangoapps/cms_user_tasks/signals.py @@ -12,6 +12,7 @@ from cms.djangoapps.contentstore.toggles import bypass_olx_failure_enabled from cms.djangoapps.contentstore.utils import course_import_olx_validation_is_enabled +from openedx.core.djangoapps.content_libraries.api import is_library_backup_task, is_library_restore_task from .tasks import send_task_complete_email @@ -64,6 +65,28 @@ def get_olx_validation_from_artifact(): if olx_artifact and not bypass_olx_failure_enabled(): return olx_artifact.text + def should_skip_end_of_task_email(task_name) -> bool: + """ + Studio tasks generally send an email when finished, but not always. + + Some tasks can last many minutes, e.g. course import/export. For these + tasks, there is a high chance that the user has navigated away and will + want to check back in later. Yet email notification is unnecessary and + distracting for things like the Library restore task, which is + relatively quick and cannot be resumed (i.e. if you navigate away, you + have to upload again). + + The task_name passed in will be lowercase. + """ + # We currently have to pattern match on the name to differentiate + # between tasks. A better long term solution would be to add a separate + # task type identifier field to Django User Tasks. + return ( + is_library_content_update(task_name) or + is_library_backup_task(task_name) or + is_library_restore_task(task_name) + ) + status = kwargs['status'] # Only send email when the entire task is complete, should only send when @@ -72,7 +95,7 @@ def get_olx_validation_from_artifact(): task_name = status.name.lower() # Also suppress emails on library content XBlock updates (too much like spam) - if is_library_content_update(task_name): + if should_skip_end_of_task_email(task_name): LOGGER.info(f"Suppressing end-of-task email on task {task_name}") return diff --git a/cms/djangoapps/contentstore/admin.py b/cms/djangoapps/contentstore/admin.py index 67bb39b7a32a..ac11ea42d0a4 100644 --- a/cms/djangoapps/contentstore/admin.py +++ b/cms/djangoapps/contentstore/admin.py @@ -100,6 +100,7 @@ class ComponentLinkAdmin(admin.ModelAdmin): "upstream_context_key", "downstream_usage_key", "downstream_context_key", + "top_level_parent", "version_synced", "version_declined", "created", @@ -139,6 +140,7 @@ class ContainerLinkAdmin(admin.ModelAdmin): "upstream_context_key", "downstream_usage_key", "downstream_context_key", + "top_level_parent", "version_synced", "version_declined", "created", diff --git a/cms/djangoapps/contentstore/api/tests/test_validation.py b/cms/djangoapps/contentstore/api/tests/test_validation.py index 4e0a9bbce666..4928d31dc1f2 100644 --- a/cms/djangoapps/contentstore/api/tests/test_validation.py +++ b/cms/djangoapps/contentstore/api/tests/test_validation.py @@ -2,9 +2,11 @@ Tests for the course import API views """ - +import factory from datetime import datetime +from django.conf import settings +import ddt from django.test.utils import override_settings from django.urls import reverse from rest_framework import status @@ -12,10 +14,13 @@ from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory +from common.djangoapps.course_modes.models import CourseMode +from common.djangoapps.course_modes.tests.factories import CourseModeFactory from common.djangoapps.student.tests.factories import StaffFactory from common.djangoapps.student.tests.factories import UserFactory +@ddt.ddt @override_settings(PROCTORING_BACKENDS={'DEFAULT': 'proctortrack', 'proctortrack': {}}) class CourseValidationViewTest(SharedModuleStoreTestCase, APITestCase): """ @@ -82,39 +87,54 @@ def test_student_fails(self): resp = self.client.get(self.get_url(self.course_key)) self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN) - def test_staff_succeeds(self): - self.client.login(username=self.staff.username, password=self.password) - resp = self.client.get(self.get_url(self.course_key), {'all': 'true'}) - self.assertEqual(resp.status_code, status.HTTP_200_OK) - expected_data = { - 'assignments': { - 'total_number': 1, - 'total_visible': 1, - 'assignments_with_dates_before_start': [], - 'assignments_with_dates_after_end': [], - 'assignments_with_ora_dates_after_end': [], - 'assignments_with_ora_dates_before_start': [], - }, - 'dates': { - 'has_start_date': True, - 'has_end_date': False, - }, - 'updates': { - 'has_update': True, - }, - 'certificates': { - 'is_enabled': False, - 'is_activated': False, - 'has_certificate': False, - }, - 'grades': { - 'has_grading_policy': False, - 'sum_of_weights': 1.0, - }, - 'proctoring': { - 'needs_proctoring_escalation_email': True, - 'has_proctoring_escalation_email': True, - }, - 'is_self_paced': True, - } - self.assertDictEqual(resp.data, expected_data) + @ddt.data( + (False, False), + (True, False), + (False, True), + (True, True), + ) + @ddt.unpack + def test_staff_succeeds(self, certs_html_view, with_modes): + features = dict(settings.FEATURES, CERTIFICATES_HTML_VIEW=certs_html_view) + with override_settings(FEATURES=features): + if with_modes: + CourseModeFactory.create_batch( + 2, + course_id=self.course.id, + mode_slug=factory.Iterator([CourseMode.AUDIT, CourseMode.VERIFIED]), + ) + self.client.login(username=self.staff.username, password=self.password) + resp = self.client.get(self.get_url(self.course_key), {'all': 'true'}) + self.assertEqual(resp.status_code, status.HTTP_200_OK) + expected_data = { + 'assignments': { + 'total_number': 1, + 'total_visible': 1, + 'assignments_with_dates_before_start': [], + 'assignments_with_dates_after_end': [], + 'assignments_with_ora_dates_after_end': [], + 'assignments_with_ora_dates_before_start': [], + }, + 'dates': { + 'has_start_date': True, + 'has_end_date': False, + }, + 'updates': { + 'has_update': True, + }, + 'certificates': { + 'is_enabled': with_modes, + 'is_activated': False, + 'has_certificate': False, + }, + 'grades': { + 'has_grading_policy': False, + 'sum_of_weights': 1.0, + }, + 'proctoring': { + 'needs_proctoring_escalation_email': True, + 'has_proctoring_escalation_email': True, + }, + 'is_self_paced': True, + } + self.assertDictEqual(resp.data, expected_data) diff --git a/cms/djangoapps/contentstore/asset_storage_handlers.py b/cms/djangoapps/contentstore/asset_storage_handlers.py index 02857b11deac..2489be61bae3 100644 --- a/cms/djangoapps/contentstore/asset_storage_handlers.py +++ b/cms/djangoapps/contentstore/asset_storage_handlers.py @@ -19,7 +19,6 @@ from opaque_keys.edx.keys import AssetKey, CourseKey from pymongo import ASCENDING, DESCENDING -from common.djangoapps.edxmako.shortcuts import render_to_response from common.djangoapps.student.auth import has_course_author_access from common.djangoapps.util.date_utils import get_default_time_display from common.djangoapps.util.json_request import JsonResponse @@ -34,8 +33,7 @@ from xmodule.modulestore.exceptions import ItemNotFoundError # lint-amnesty, pylint: disable=wrong-import-order from .exceptions import AssetNotFoundException, AssetSizeTooLargeException -from .utils import reverse_course_url, get_files_uploads_url, get_response_format, request_response_format_is_json -from .toggles import use_new_files_uploads_page +from .utils import get_files_uploads_url, get_response_format, request_response_format_is_json REQUEST_DEFAULTS = { @@ -169,22 +167,8 @@ def _get_asset_usage_path(course_key, assets): def _asset_index(request, course_key): ''' Display an editable asset library. - - Supports start (0-based index into the list of assets) and max query parameters. ''' - 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, - 'max_file_size_in_mbs': settings.MAX_ASSET_UPLOAD_FILE_SIZE_IN_MB, - 'chunk_size_in_mbs': settings.UPLOAD_CHUNK_SIZE_IN_MB, - 'max_file_size_redirect_url': settings.MAX_ASSET_UPLOAD_FILE_SIZE_URL, - 'asset_callback_url': reverse_course_url('assets_handler', course_key) - }) + return redirect(get_files_uploads_url(course_key)) def _assets_json(request, course_key): diff --git a/cms/djangoapps/contentstore/helpers.py b/cms/djangoapps/contentstore/helpers.py index 91236a4dade9..c5890b5b818b 100644 --- a/cms/djangoapps/contentstore/helpers.py +++ b/cms/djangoapps/contentstore/helpers.py @@ -529,7 +529,7 @@ def _import_xml_node_to_parent( node_copied_version = node.attrib.get('copied_from_version', None) # Modulestore's IdGenerator here is SplitMongoIdManager which is assigned - # by CachingDescriptorSystem Runtime and since we need our custom ImportIdGenerator + # by SplitModuleStoreRuntime and since we need our custom ImportIdGenerator # here we are temporaraliy swtiching it. original_id_generator = runtime.id_generator @@ -566,7 +566,8 @@ def _import_xml_node_to_parent( else: # We have to handle the children ourselves, because there are lots of complex interactions between # * the vanilla XBlock parse_xml() method, and its lack of API for "create and save a new XBlock" - # * the XmlMixin version of parse_xml() which only works with ImportSystem, not modulestore or the v2 runtime + # * the XmlMixin version of parse_xml() which only works with XMLImportingModuleStoreRuntime, + # not modulestore or the v2 runtime # * the modulestore APIs for creating and saving a new XBlock, which work but don't support XML parsing. # We can safely assume that if the XBLock class supports children, every child node will be the XML # serialization of a child block, in order. For blocks that don't support children, their XML content/nodes diff --git a/cms/djangoapps/contentstore/migrations/0014_remove_componentlink_downstream_is_modified_and_more.py b/cms/djangoapps/contentstore/migrations/0014_remove_componentlink_downstream_is_modified_and_more.py new file mode 100644 index 000000000000..30949bcd272f --- /dev/null +++ b/cms/djangoapps/contentstore/migrations/0014_remove_componentlink_downstream_is_modified_and_more.py @@ -0,0 +1,43 @@ +# Generated by Django 5.2.7 on 2025-10-27 14:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('contentstore', '0013_componentlink_downstream_is_modified_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='componentlink', + name='downstream_is_modified', + ), + migrations.RemoveField( + model_name='containerlink', + name='downstream_is_modified', + ), + migrations.AddField( + model_name='componentlink', + name='downstream_customized', + field=models.JSONField( + default=list, + help_text=( + 'Names of the fields which have values set on the upstream block yet have been explicitly' + ' overridden on this downstream block' + ), + ), + ), + migrations.AddField( + model_name='containerlink', + name='downstream_customized', + field=models.JSONField( + default=list, + help_text=( + 'Names of the fields which have values set on the upstream block yet have been explicitly' + ' overridden on this downstream block' + ), + ), + ), + ] diff --git a/cms/djangoapps/contentstore/models.py b/cms/djangoapps/contentstore/models.py index 47a450cde867..a4f2ce3c6119 100644 --- a/cms/djangoapps/contentstore/models.py +++ b/cms/djangoapps/contentstore/models.py @@ -7,12 +7,12 @@ from config_models.models import ConfigurationModel from django.db import models -from django.db.models import QuerySet, OuterRef, Case, When, Exists, Value, ExpressionWrapper -from django.db.models.fields import IntegerField, TextField, BooleanField +from django.db.models import Case, Exists, ExpressionWrapper, OuterRef, Q, QuerySet, Value, When +from django.db.models.fields import BooleanField, IntegerField, TextField from django.db.models.functions import Coalesce from django.db.models.lookups import GreaterThan from django.utils.translation import gettext_lazy as _ -from opaque_keys.edx.django.models import CourseKeyField, ContainerKeyField, UsageKeyField +from opaque_keys.edx.django.models import ContainerKeyField, CourseKeyField, UsageKeyField from opaque_keys.edx.keys import CourseKey, UsageKey from opaque_keys.edx.locator import LibraryContainerLocator from openedx_learning.api.authoring import get_published_version @@ -23,7 +23,6 @@ manual_date_time_field, ) - logger = logging.getLogger(__name__) @@ -108,7 +107,13 @@ class EntityLinkBase(models.Model): top_level_parent = models.ForeignKey("ContainerLink", on_delete=models.SET_NULL, null=True, blank=True) version_synced = models.IntegerField() version_declined = models.IntegerField(null=True, blank=True) - downstream_is_modified = models.BooleanField(default=False) + downstream_customized = models.JSONField( + default=list, + help_text=( + 'Names of the fields which have values set on the upstream block yet have been explicitly' + ' overridden on this downstream block' + ), + ) created = manual_date_time_field() updated = manual_date_time_field() @@ -258,7 +263,7 @@ def update_or_create( version_synced: int, top_level_parent_usage_key: UsageKey | None = None, version_declined: int | None = None, - downstream_is_modified: bool = False, + downstream_customized: list[str] | None = None, created: datetime | None = None, ) -> "ComponentLink": """ @@ -283,7 +288,7 @@ def update_or_create( 'version_synced': version_synced, 'version_declined': version_declined, 'top_level_parent': top_level_parent, - 'downstream_is_modified': downstream_is_modified, + 'downstream_customized': downstream_customized, } if upstream_block: new_values['upstream_block'] = upstream_block @@ -385,7 +390,7 @@ def filter_links( cls.objects.filter(**link_filter).select_related(*RELATED_FIELDS), ) if ready_to_sync is not None: - result = result.filter(ready_to_sync=ready_to_sync) + result = result.filter(Q(ready_to_sync=ready_to_sync) | Q(ready_to_sync_from_children=ready_to_sync)) # Handle top-level parents logic if use_top_level_parents: @@ -430,6 +435,11 @@ def _annotate_query_with_ready_to_sync(cls, query_set: QuerySet["EntityLinkBase" ), then=1 ), + # If upstream block was deleted, set ready_to_sync = True + When( + Q(upstream_container__publishable_entity__published__version__version_num__isnull=True), + then=1 + ), default=0, output_field=models.IntegerField() ) @@ -451,6 +461,11 @@ def _annotate_query_with_ready_to_sync(cls, query_set: QuerySet["EntityLinkBase" ), then=1 ), + # If upstream block was deleted, set ready_to_sync = True + When( + Q(upstream_block__publishable_entity__published__version__version_num__isnull=True), + then=1 + ), default=0, output_field=models.IntegerField() ) @@ -485,7 +500,7 @@ def update_or_create( version_synced: int, top_level_parent_usage_key: UsageKey | None = None, version_declined: int | None = None, - downstream_is_modified: bool = False, + downstream_customized: list[str] | None = None, created: datetime | None = None, ) -> "ContainerLink": """ @@ -510,7 +525,7 @@ def update_or_create( 'version_synced': version_synced, 'version_declined': version_declined, 'top_level_parent': top_level_parent, - 'downstream_is_modified': downstream_is_modified, + 'downstream_customized': downstream_customized, } if upstream_container_id: new_values['upstream_container_id'] = upstream_container_id diff --git a/cms/djangoapps/contentstore/rest_api/v1/serializers/course_waffle_flags.py b/cms/djangoapps/contentstore/rest_api/v1/serializers/course_waffle_flags.py index fde90163f803..a5fb3d93fc6e 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/serializers/course_waffle_flags.py +++ b/cms/djangoapps/contentstore/rest_api/v1/serializers/course_waffle_flags.py @@ -42,9 +42,15 @@ def get_course_key(self): def get_use_new_home_page(self, obj): """ - Method to get the use_new_home_page switch + Method to indicate whether we should use the new home page. + + This used to be based on a waffle flag but the flag is being removed so we + default it to true for now until we can remove the need for it from the consumers + of this serializer and the related APIs. + + See https://github.com/openedx/edx-platform/issues/37497 """ - return toggles.use_new_home_page() + return True def get_use_new_custom_pages(self, obj): """ @@ -98,9 +104,11 @@ def get_use_new_export_page(self, obj): def get_use_new_files_uploads_page(self, obj): """ Method to get the use_new_files_uploads_page switch + + Always true, because the switch is being removed an the new experience + should alawys be on. """ - course_key = self.get_course_key() - return toggles.use_new_files_uploads_page(course_key) + return True def get_use_new_video_uploads_page(self, obj): """ @@ -112,9 +120,12 @@ def get_use_new_video_uploads_page(self, obj): def get_use_new_course_outline_page(self, obj): """ Method to get the use_new_course_outline_page switch + + Always true, because the switch is being removed and the new experience + should always be on. This function will be removed in + https://github.com/openedx/edx-platform/issues/37497 """ - course_key = self.get_course_key() - return toggles.use_new_course_outline_page(course_key) + return True def get_use_new_unit_page(self, obj): """ diff --git a/cms/djangoapps/contentstore/rest_api/v1/serializers/home.py b/cms/djangoapps/contentstore/rest_api/v1/serializers/home.py index bbc45ddf9a37..8ee8cb035478 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/serializers/home.py +++ b/cms/djangoapps/contentstore/rest_api/v1/serializers/home.py @@ -28,26 +28,11 @@ class LibraryViewSerializer(serializers.Serializer): org = serializers.CharField() number = serializers.CharField() can_edit = serializers.BooleanField() - is_migrated = serializers.SerializerMethodField() - migrated_to_title = serializers.CharField( - source="migrations__target__title", - required=False - ) - migrated_to_key = serializers.CharField( - source="migrations__target__key", - required=False - ) - migrated_to_collection_key = serializers.CharField( - source="migrations__target_collection__key", - required=False - ) - migrated_to_collection_title = serializers.CharField( - source="migrations__target_collection__title", - required=False - ) - - def get_is_migrated(self, obj): - return "migrations__target__key" in obj + is_migrated = serializers.BooleanField() + migrated_to_title = serializers.CharField(required=False) + migrated_to_key = serializers.CharField(required=False) + migrated_to_collection_key = serializers.CharField(required=False) + migrated_to_collection_title = serializers.CharField(required=False) class CourseHomeTabSerializer(serializers.Serializer): diff --git a/cms/djangoapps/contentstore/rest_api/v1/serializers/vertical_block.py b/cms/djangoapps/contentstore/rest_api/v1/serializers/vertical_block.py index 84598fbcd9c9..eb4f333e170a 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/serializers/vertical_block.py +++ b/cms/djangoapps/contentstore/rest_api/v1/serializers/vertical_block.py @@ -124,7 +124,7 @@ class UpstreamLinkSerializer(serializers.Serializer): version_declined = serializers.IntegerField(allow_null=True) error_message = serializers.CharField(allow_null=True) ready_to_sync = serializers.BooleanField() - is_modified = serializers.BooleanField() + downstream_customized = serializers.ListField(child=serializers.CharField(), allow_empty=True) has_top_level_parent = serializers.BooleanField() ready_to_sync_children = UpstreamChildrenInfoSerializer(many=True, required=False) @@ -177,6 +177,22 @@ class ContainerChildrenSerializer(serializers.Serializer): Serializer for representing a vertical container with state and children. """ + class UpstreamReadyToSyncChildrenInfoSerializer(serializers.Serializer): + """ + Serializer used for the `upstream_ready_to_sync_children_info` field + """ + id = serializers.CharField() + name = serializers.CharField() + upstream = serializers.CharField() + block_type = serializers.CharField() + downstream_customized = serializers.ListField(child=serializers.CharField(), allow_empty=True) + children = ContainerChildSerializer(many=True) is_published = serializers.BooleanField() can_paste_component = serializers.BooleanField() + display_name = serializers.CharField() + upstream_ready_to_sync_children_info = UpstreamReadyToSyncChildrenInfoSerializer( + many=True, + required=False, + help_text="List of dictionaries describing upstream child components readiness to sync." + ) diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/course_index.py b/cms/djangoapps/contentstore/rest_api/v1/views/course_index.py index f79892658da0..42b5b1e9d78d 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/course_index.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/course_index.py @@ -8,6 +8,7 @@ from rest_framework.request import Request from rest_framework.response import Response from rest_framework.views import APIView +from rest_framework.fields import BooleanField from cms.djangoapps.contentstore.config.waffle import CUSTOM_RELATIVE_DATES from cms.djangoapps.contentstore.rest_api.v1.mixins import ContainerHandlerMixin @@ -129,6 +130,11 @@ class ContainerChildrenView(APIView, ContainerHandlerMixin): apidocs.ParameterLocation.PATH, description="Container usage key", ), + apidocs.string_parameter( + "get_upstream_info", + apidocs.ParameterLocation.QUERY, + description="Gets the info of all ready to sync children", + ), ], responses={ 200: ContainerChildrenSerializer, @@ -210,6 +216,7 @@ def get(self, request: Request, usage_key_string: str): "version_available": 49, "error_message": null, "ready_to_sync": true, + "is_ready_to_sync_individually": true, }, "actions": { "can_copy": true, @@ -230,11 +237,20 @@ def get(self, request: Request, usage_key_string: str): ], "is_published": false, "can_paste_component": true, + "display_name": "Vertical block 1" + "upstream_ready_to_sync_children_info": [{ + "name": "Text", + "upstream": "lb:org:mylib:html:abcd", + 'block_type': "html", + 'downstream_customized': ["display_name"], + 'id': "block-v1:org+101+101+type@html+block@3e3fa1f88adb4a108cd14e9002143690", + }] } ``` """ usage_key = self.get_object(usage_key_string) current_xblock = get_xblock(usage_key, request.user) + get_upstream_info = BooleanField().to_internal_value(request.GET.get("get_upstream_info", False)) is_course = current_xblock.scope_ids.usage_id.context_key.is_course with modulestore().bulk_operations(usage_key.course_key): @@ -273,10 +289,18 @@ def get(self, request: Request, usage_key_string: str): except ItemNotFoundError: logging.error('Could not find any changes for block [%s]', usage_key) + upstream_ready_to_sync_children_info = [] + if current_xblock.upstream and get_upstream_info: + upstream_link = UpstreamLink.get_for_block(current_xblock) + upstream_link_data = upstream_link.to_json(include_child_info=True) + upstream_ready_to_sync_children_info = upstream_link_data["ready_to_sync_children"] + container_data = { "children": children, "is_published": is_published, "can_paste_component": is_course, + "upstream_ready_to_sync_children_info": upstream_ready_to_sync_children_info, + "display_name": current_xblock.display_name_with_default, } serializer = ContainerChildrenSerializer(container_data) return Response(serializer.data) diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/home.py b/cms/djangoapps/contentstore/rest_api/v1/views/home.py index a4e93de9caff..95723020c11f 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/home.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/home.py @@ -236,7 +236,7 @@ def get(self, request: Request): "number": "CPSPR", "can_edit": true } - ], } + ], ``` """ 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 cd7592c46629..72d58fa00dfa 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 @@ -18,7 +18,6 @@ from cms.djangoapps.contentstore.tests.utils import CourseTestCase from cms.djangoapps.modulestore_migrator import api as migrator_api from cms.djangoapps.modulestore_migrator.data import CompositionLevel, RepeatHandlingStrategy -from cms.djangoapps.modulestore_migrator.tests.factories import ModulestoreSourceFactory from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory from openedx.core.djangoapps.content_libraries import api as lib_api @@ -253,8 +252,9 @@ class HomePageLibrariesViewTest(LibraryTestCase): def setUp(self): super().setUp() - # Create an additional legacy library + # Create an two additional legacy libaries self.lib_key_1 = self._create_library(library="lib1") + self.lib_key_2 = self._create_library(library="lib2") self.organization = OrganizationFactory() # Create a new v2 library @@ -269,7 +269,6 @@ def setUp(self): library = lib_api.ContentLibrary.objects.get(slug=self.lib_key_v2.slug) learning_package = library.learning_package # Create a migration source for the legacy library - self.source = ModulestoreSourceFactory(key=self.lib_key_1) self.url = reverse("cms.djangoapps.contentstore:v1:libraries") # Create a collection to migrate this library to collection_key = "test-collection" @@ -280,20 +279,32 @@ def setUp(self): created_by=self.user.id, ) - # Migrate self.lib_key_1 to self.lib_key_v2 + # Migrate both lib_key_1 and lib_key_2 to v2 + # Only make lib_key_1 a "forwarding" migration. migrator_api.start_migration_to_library( user=self.user, - source_key=self.source.key, + source_key=self.lib_key_1, target_library_key=self.lib_key_v2, target_collection_slug=collection_key, - composition_level=CompositionLevel.Component.value, - repeat_handling_strategy=RepeatHandlingStrategy.Skip.value, + composition_level=CompositionLevel.Component, + repeat_handling_strategy=RepeatHandlingStrategy.Skip, + preserve_url_slugs=True, + forward_source_to_target=True, + ) + migrator_api.start_migration_to_library( + user=self.user, + source_key=self.lib_key_2, + target_library_key=self.lib_key_v2, + target_collection_slug=collection_key, + composition_level=CompositionLevel.Component, + repeat_handling_strategy=RepeatHandlingStrategy.Skip, preserve_url_slugs=True, forward_source_to_target=False, ) def test_home_page_libraries_response(self): - """Check successful response content""" + """Check sucessful response content""" + self.maxDiff = None response = self.client.get(self.url) expected_response = { @@ -322,6 +333,17 @@ def test_home_page_libraries_response(self): 'migrated_to_collection_key': 'test-collection', 'migrated_to_collection_title': 'Test Collection', }, + # Third library was migrated, but not with forwarding. + # So, it appears just like the unmigrated library. + { + 'display_name': 'Test Library', + 'library_key': 'library-v1:org+lib2', + 'url': '/library/library-v1:org+lib2', + 'org': 'org', + 'number': 'lib2', + 'can_edit': True, + 'is_migrated': False, + }, ] } @@ -366,6 +388,15 @@ def test_home_page_libraries_response(self): 'can_edit': True, 'is_migrated': False, }, + { + 'display_name': 'Test Library', + 'library_key': 'library-v1:org+lib2', + 'url': '/library/library-v1:org+lib2', + 'org': 'org', + 'number': 'lib2', + 'can_edit': True, + 'is_migrated': False, + }, ], } diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_vertical_block.py b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_vertical_block.py index 16e6679833a2..a7cf3a452627 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_vertical_block.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_vertical_block.py @@ -11,6 +11,7 @@ from cms.djangoapps.contentstore.tests.utils import CourseTestCase from openedx.core.djangoapps.content_tagging.toggles import DISABLE_TAGGING_FEATURE +from openedx.core.djangoapps.content_libraries.tests import ContentLibrariesRestApiTest from xmodule.partitions.partitions import ( ENROLLMENT_TRACK_PARTITION_ID, Group, @@ -27,7 +28,7 @@ ) # lint-amnesty, pylint: disable=wrong-import-order -class BaseXBlockContainer(CourseTestCase): +class BaseXBlockContainer(CourseTestCase, ContentLibrariesRestApiTest): """ Base xBlock container handler. @@ -48,6 +49,20 @@ def setup_xblock(self): This method creates XBlock objects representing a course structure with chapters, sequentials, verticals and others. """ + self.lib = self._create_library( + slug="containers", + title="Container Test Library", + description="Units and more", + ) + self.unit = self._create_container(self.lib["id"], "unit", display_name="Unit", slug=None) + self.html_block = self._add_block_to_library(self.lib["id"], "html", "Html1", can_stand_alone=False) + self._set_library_block_olx( + self.html_block["id"], + 'updated content upstream 1' + ) + # Set version of html to 2 + self._publish_library_block(self.html_block["id"]) + self.chapter = self.create_block( parent=self.course.location, category="chapter", @@ -60,7 +75,13 @@ def setup_xblock(self): display_name="Lesson 1", ) - self.vertical = self.create_block(self.sequential.location, "vertical", "Unit") + self.vertical = self.create_block( + self.sequential.location, + "vertical", + "Unit", + upstream=self.unit["id"], + upstream_version=1, + ) self.html_unit_first = self.create_block( parent=self.vertical.location, @@ -72,8 +93,8 @@ def setup_xblock(self): parent=self.vertical.location, category="html", display_name="Html Content 2", - upstream="lb:FakeOrg:FakeLib:html:FakeBlock", - upstream_version=5, + upstream=self.html_block["id"], + upstream_version=1, ) def create_block(self, parent, category, display_name, **kwargs): @@ -204,9 +225,32 @@ def test_success_response(self): url = self.get_reverse_url(self.vertical.location) response = self.client.get(url) self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(len(response.data["children"]), 2) - self.assertFalse(response.data["is_published"]) - self.assertTrue(response.data["can_paste_component"]) + data = response.json() + self.assertEqual(len(data["children"]), 2) + self.assertFalse(data["is_published"]) + self.assertTrue(data["can_paste_component"]) + self.assertEqual(data["display_name"], "Unit") + self.assertEqual(data["upstream_ready_to_sync_children_info"], []) + + def test_success_response_with_upstream_info(self): + """ + Check that endpoint returns valid response data using `get_upstream_info` query param + """ + url = self.get_reverse_url(self.vertical.location) + response = self.client.get(f"{url}?get_upstream_info=true") + self.assertEqual(response.status_code, status.HTTP_200_OK) + data = response.json() + self.assertEqual(len(data["children"]), 2) + self.assertFalse(data["is_published"]) + self.assertTrue(data["can_paste_component"]) + self.assertEqual(data["display_name"], "Unit") + self.assertEqual(data["upstream_ready_to_sync_children_info"], [{ + "id": str(self.html_unit_second.usage_key), + "upstream": self.html_block["id"], + "block_type": "html", + "downstream_customized": [], + "name": "Html Content 2", + }]) def test_xblock_is_published(self): """ @@ -273,14 +317,14 @@ def test_children_content(self): "can_manage_tags": True, }, "upstream_link": { - "upstream_ref": "lb:FakeOrg:FakeLib:html:FakeBlock", - "version_synced": 5, - "version_available": None, + "upstream_ref": self.html_block["id"], + "version_synced": 1, + "version_available": 2, "version_declined": None, - "error_message": "Linked upstream library block was not found in the system", - "ready_to_sync": False, + "error_message": None, + "ready_to_sync": True, "has_top_level_parent": False, - "is_modified": False, + "downstream_customized": [], }, "user_partition_info": expected_user_partition_info, "user_partitions": expected_user_partitions, diff --git a/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstream_sync_integration.py b/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstream_sync_integration.py index e5cd063e8181..9733f9878210 100644 --- a/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstream_sync_integration.py +++ b/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstream_sync_integration.py @@ -11,15 +11,14 @@ from freezegun import freeze_time from openedx.core.djangoapps.content_libraries.tests import ContentLibrariesRestApiTest -from cms.djangoapps.contentstore.xblock_storage_handlers.xblock_helpers import get_block_key_dict +from cms.djangoapps.contentstore.xblock_storage_handlers.xblock_helpers import get_block_key_string from xmodule.modulestore.django import modulestore -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, ImmediateOnCommitMixin from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory -from xmodule.xml_block import serialize_field @ddt.ddt -class CourseToLibraryTestCase(ContentLibrariesRestApiTest, ModuleStoreTestCase): +class CourseToLibraryTestCase(ContentLibrariesRestApiTest, ImmediateOnCommitMixin, ModuleStoreTestCase): """ Tests that involve syncing content from libraries to courses. """ @@ -197,7 +196,7 @@ def test_problem_sync(self): 'version_declined': None, 'ready_to_sync': False, 'error_message': None, - 'is_modified': False, + 'downstream_customized': [], # 'upstream_link': 'http://course-authoring-mfe/library/lib:CL-TEST:testlib/components?usageKey=...' }) assert status["upstream_link"].startswith("http://course-authoring-mfe/library/") @@ -254,7 +253,7 @@ def test_problem_sync(self): 'version_declined': None, 'ready_to_sync': True, # <--- updated 'error_message': None, - 'is_modified': True, + 'downstream_customized': ['display_name'], }) # 3️⃣ Now, sync and check the resulting OLX of the downstream @@ -296,9 +295,9 @@ def test_unit_sync(self): parent_usage_key=str(self.course_subsection.usage_key), upstream_key=self.upstream_unit["id"], ) - downstream_unit_block_key = serialize_field(get_block_key_dict( + downstream_unit_block_key = get_block_key_string( UsageKey.from_string(downstream_unit["locator"]), - )).replace('"', '"') + ) status = self._get_sync_status(downstream_unit["locator"]) self.assertDictContainsEntries(status, { 'upstream_ref': self.upstream_unit["id"], # e.g. 'lct:CL-TEST:testlib:unit:u1' @@ -307,7 +306,7 @@ def test_unit_sync(self): 'version_declined': None, 'ready_to_sync': False, 'error_message': None, - 'is_modified': False, + 'downstream_customized': [], # 'upstream_link': 'http://course-authoring-mfe/library/lib:CL-TEST:testlib/units/...' }) assert status["upstream_link"].startswith("http://course-authoring-mfe/library/") @@ -384,7 +383,7 @@ def test_unit_sync(self): 'updated': date_format, 'upstream_key': self.upstream_html1["id"], 'upstream_type': 'component', - 'downstream_is_modified': False, + 'downstream_customized': [], }, { 'id': 2, @@ -402,7 +401,7 @@ def test_unit_sync(self): 'updated': date_format, 'upstream_key': self.upstream_problem1["id"], 'upstream_type': 'component', - 'downstream_is_modified': False, + 'downstream_customized': [], }, { 'id': 3, @@ -420,7 +419,7 @@ def test_unit_sync(self): 'updated': date_format, 'upstream_key': self.upstream_problem2["id"], 'upstream_type': 'component', - 'downstream_is_modified': False, + 'downstream_customized': [], }, { 'id': 1, @@ -438,7 +437,7 @@ def test_unit_sync(self): 'updated': date_format, 'upstream_key': self.upstream_unit["id"], 'upstream_type': 'container', - 'downstream_is_modified': False, + 'downstream_customized': [], } ] data = downstreams.json() @@ -460,7 +459,7 @@ def test_unit_sync(self): 'version_declined': None, 'ready_to_sync': True, # <--- It's the top-level parent of the block 'error_message': None, - 'is_modified': False, + 'downstream_customized': [], }) # Check the upstream/downstream status of [one of] the children @@ -472,7 +471,7 @@ def test_unit_sync(self): 'version_declined': None, 'ready_to_sync': False, # <-- It has top-level parent, the parent is the one who must synchronize 'error_message': None, - 'is_modified': False, + 'downstream_customized': [], }) # Sync and check the resulting OLX of the downstream @@ -537,7 +536,7 @@ def test_unit_sync(self): 'updated': date_format, 'upstream_key': self.upstream_html1["id"], 'upstream_type': 'component', - 'downstream_is_modified': False, + 'downstream_customized': [], }, { 'id': 2, @@ -555,7 +554,7 @@ def test_unit_sync(self): 'updated': date_format, 'upstream_key': self.upstream_problem1["id"], 'upstream_type': 'component', - 'downstream_is_modified': False, + 'downstream_customized': [], }, { 'id': 3, @@ -573,7 +572,7 @@ def test_unit_sync(self): 'updated': date_format, 'upstream_key': self.upstream_problem2["id"], 'upstream_type': 'component', - 'downstream_is_modified': False, + 'downstream_customized': [], }, { 'id': 1, @@ -591,7 +590,7 @@ def test_unit_sync(self): 'updated': date_format, 'upstream_key': self.upstream_unit["id"], 'upstream_type': 'container', - 'downstream_is_modified': False, + 'downstream_customized': [], } ] data = downstreams.json() @@ -621,7 +620,7 @@ def test_unit_sync(self): 'version_declined': None, 'ready_to_sync': True, 'error_message': None, - 'is_modified': False, + 'downstream_customized': [], }) # Sync and check the resulting OLX of the downstream @@ -689,7 +688,7 @@ def test_unit_sync(self): 'updated': date_format, 'upstream_key': self.upstream_html1["id"], 'upstream_type': 'component', - 'downstream_is_modified': False, + 'downstream_customized': [], }, { 'id': 2, @@ -707,7 +706,7 @@ def test_unit_sync(self): 'updated': date_format, 'upstream_key': self.upstream_problem1["id"], 'upstream_type': 'component', - 'downstream_is_modified': False, + 'downstream_customized': [], }, { 'id': 4, @@ -725,7 +724,7 @@ def test_unit_sync(self): 'updated': date_format, 'upstream_key': upstream_problem3["id"], 'upstream_type': 'component', - 'downstream_is_modified': False, + 'downstream_customized': [], }, { 'id': 1, @@ -743,7 +742,7 @@ def test_unit_sync(self): 'updated': date_format, 'upstream_key': self.upstream_unit["id"], 'upstream_type': 'container', - 'downstream_is_modified': False, + 'downstream_customized': [], } ] data = downstreams.json() @@ -822,7 +821,7 @@ def test_unit_sync(self): 'updated': date_format, 'upstream_key': self.upstream_html1["id"], 'upstream_type': 'component', - 'downstream_is_modified': False, + 'downstream_customized': [], }, { 'id': 2, @@ -840,7 +839,7 @@ def test_unit_sync(self): 'updated': date_format, 'upstream_key': self.upstream_problem1["id"], 'upstream_type': 'component', - 'downstream_is_modified': False, + 'downstream_customized': [], }, { 'id': 4, @@ -858,7 +857,7 @@ def test_unit_sync(self): 'updated': date_format, 'upstream_key': upstream_problem3["id"], 'upstream_type': 'component', - 'downstream_is_modified': False, + 'downstream_customized': [], }, { 'id': 1, @@ -876,7 +875,7 @@ def test_unit_sync(self): 'updated': date_format, 'upstream_key': self.upstream_unit["id"], 'upstream_type': 'container', - 'downstream_is_modified': False, + 'downstream_customized': [], } ] data = downstreams.json() @@ -898,9 +897,9 @@ def test_unit_sync_with_modified_downstream(self): parent_usage_key=str(self.course_subsection.usage_key), upstream_key=self.upstream_unit["id"], ) - downstream_unit_block_key = serialize_field(get_block_key_dict( + downstream_unit_block_key = get_block_key_string( UsageKey.from_string(downstream_unit["locator"]), - )).replace('"', '"') + ) status = self._get_sync_status(downstream_unit["locator"]) self.assertDictContainsEntries(status, { 'upstream_ref': self.upstream_unit["id"], # e.g. 'lct:CL-TEST:testlib:unit:u1' @@ -909,7 +908,7 @@ def test_unit_sync_with_modified_downstream(self): 'version_declined': None, 'ready_to_sync': False, 'error_message': None, - 'is_modified': False, + 'downstream_customized': [], # 'upstream_link': 'http://course-authoring-mfe/library/lib:CL-TEST:testlib/units/...' }) assert status["upstream_link"].startswith("http://course-authoring-mfe/library/") @@ -984,7 +983,7 @@ def test_unit_sync_with_modified_downstream(self): 'version_declined': None, 'ready_to_sync': True, # <--- It's the top-level parent of the block 'error_message': None, - 'is_modified': False, + 'downstream_customized': [], }) # Check the upstream/downstream status of [one of] the children @@ -996,7 +995,7 @@ def test_unit_sync_with_modified_downstream(self): 'version_declined': None, 'ready_to_sync': False, # <-- It has top-level parent, the parent is the one who must synchronize 'error_message': None, - 'is_modified': False, + 'downstream_customized': [], }) self.assertDictContainsEntries(self._get_sync_status(downstream_html1), { @@ -1006,7 +1005,7 @@ def test_unit_sync_with_modified_downstream(self): 'version_declined': None, 'ready_to_sync': False, # <-- It has top-level parent, the parent is the one who must synchronize 'error_message': None, - 'is_modified': False, + 'downstream_customized': [], }) # Now let's modify course html block @@ -1077,7 +1076,7 @@ def test_modified_html_copy_paste(self): 'version_declined': None, 'ready_to_sync': False, 'error_message': None, - 'is_modified': False, + 'downstream_customized': [], # 'upstream_link': 'http://course-authoring-mfe/library/lib:CL-TEST:testlib/components?usageKey=...' }) assert status["upstream_link"].startswith("http://course-authoring-mfe/library/") @@ -1117,7 +1116,7 @@ def test_modified_html_copy_paste(self): 'updated': date_format, 'upstream_key': self.upstream_html1["id"], 'upstream_type': 'component', - 'downstream_is_modified': False, + 'downstream_customized': [], }, ] data = downstreams.json() @@ -1156,7 +1155,7 @@ def test_modified_html_copy_paste(self): 'version_declined': None, 'ready_to_sync': True, # <--- updated 'error_message': None, - 'is_modified': True, # <--- updated + 'downstream_customized': ['display_name'], }) downstreams = self._get_downstream_links( @@ -1179,7 +1178,7 @@ def test_modified_html_copy_paste(self): 'updated': date_format, 'upstream_key': self.upstream_html1["id"], 'upstream_type': 'component', - 'downstream_is_modified': True, # <--- updated + 'downstream_customized': ["display_name"], # <--- updated }, ] data = downstreams.json() @@ -1259,9 +1258,9 @@ def test_unit_decline_sync(self): parent_usage_key=str(self.course_subsection.usage_key), upstream_key=self.upstream_unit["id"], ) - downstream_unit_block_key = serialize_field(get_block_key_dict( + downstream_unit_block_key = get_block_key_string( UsageKey.from_string(downstream_unit["locator"]), - )).replace('"', '"') + ) children_downstream_keys = self._get_course_block_children(downstream_unit["locator"]) downstream_problem1 = children_downstream_keys[1] assert "type@problem" in downstream_problem1 diff --git a/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstreams.py b/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstreams.py index b8f2c8f41057..b33d980732fa 100644 --- a/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstreams.py +++ b/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstreams.py @@ -16,14 +16,14 @@ from cms.djangoapps.contentstore.helpers import StaticFileNotices from cms.djangoapps.contentstore.tests.utils import CourseTestCase from cms.djangoapps.contentstore.xblock_storage_handlers import view_handlers as xblock_view_handlers -from cms.djangoapps.contentstore.xblock_storage_handlers.xblock_helpers import get_block_key_dict +from cms.djangoapps.contentstore.xblock_storage_handlers.xblock_helpers import get_block_key_string from cms.lib.xblock.upstream_sync import BadUpstream, UpstreamLink from common.djangoapps.student.auth import add_users from common.djangoapps.student.roles import CourseStaffRole from common.djangoapps.student.tests.factories import UserFactory from openedx.core.djangoapps.content_libraries import api as lib_api from xmodule.modulestore.django import modulestore -from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase +from xmodule.modulestore.tests.django_utils import ImmediateOnCommitMixin, SharedModuleStoreTestCase from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory from .. import downstreams as downstreams_views @@ -32,6 +32,7 @@ URL_PREFIX = '/api/libraries/v2/' URL_LIB_CREATE = URL_PREFIX URL_LIB_BLOCKS = URL_PREFIX + '{lib_key}/blocks/' +URL_LIB_BLOCK = URL_PREFIX + 'blocks/{block_key}/' URL_LIB_BLOCK_PUBLISH = URL_PREFIX + 'blocks/{block_key}/publish/' URL_LIB_BLOCK_OLX = URL_PREFIX + 'blocks/{block_key}/olx/' URL_LIB_CONTAINER = URL_PREFIX + 'containers/{container_key}/' # Get a container in this library @@ -48,7 +49,7 @@ def _get_upstream_link_good_and_syncable(downstream): version_available=(downstream.upstream_version or 0) + 1, version_declined=downstream.upstream_version_declined, error_message=None, - is_modified=False, + downstream_customized=[], has_top_level_parent=False, ) @@ -157,7 +158,7 @@ def setUp(self): parent=self.top_level_downstream_unit, upstream=self.html_lib_id_2, upstream_version=1, - top_level_downstream_parent_key=get_block_key_dict( + top_level_downstream_parent_key=get_block_key_string( self.top_level_downstream_unit.usage_key, ) ).usage_key @@ -171,7 +172,7 @@ def setUp(self): parent=self.top_level_downstream_chapter, upstream=self.top_level_subsection_id, upstream_version=1, - top_level_downstream_parent_key=get_block_key_dict( + top_level_downstream_parent_key=get_block_key_string( self.top_level_downstream_chapter.usage_key, ), ) @@ -180,7 +181,7 @@ def setUp(self): parent=self.top_level_downstream_sequential, upstream=self.top_level_unit_id_2, upstream_version=1, - top_level_downstream_parent_key=get_block_key_dict( + top_level_downstream_parent_key=get_block_key_string( self.top_level_downstream_chapter.usage_key, ), ) @@ -189,7 +190,7 @@ def setUp(self): parent=self.top_level_downstream_unit_2, upstream=self.video_lib_id_2, upstream_version=1, - top_level_downstream_parent_key=get_block_key_dict( + top_level_downstream_parent_key=get_block_key_string( self.top_level_downstream_chapter.usage_key, ) ).usage_key @@ -277,6 +278,10 @@ def _create_container(self, lib_key, container_type, slug: str | None, display_n data["slug"] = slug return self._api('post', URL_LIB_CONTAINERS.format(lib_key=lib_key), data, expect_response) + def _delete_component(self, block_key, expect_response=200): + """ Publish all changes in the specified container + children """ + return self._api('delete', URL_LIB_BLOCK.format(block_key=block_key), None, expect_response) + class SharedErrorTestCases(_BaseDownstreamViewTestMixin): """ @@ -406,7 +411,7 @@ def test_400(self, sync: str): assert video_after.upstream is None -class DeleteDownstreamViewTest(SharedErrorTestCases, SharedModuleStoreTestCase): +class DeleteDownstreamViewTest(SharedErrorTestCases, ImmediateOnCommitMixin, SharedModuleStoreTestCase): """ Test that `DELETE /api/v2/contentstore/downstreams/...` severs a downstream's link to an upstream. """ @@ -455,17 +460,14 @@ def test_unlink_parent_should_update_children_top_level_parent(self): unit = modulestore().get_item(self.top_level_downstream_unit_2.usage_key) # The sequential is the top-level parent for the unit - assert unit.top_level_downstream_parent_key == { - "id": str(self.top_level_downstream_sequential.usage_key.block_id), - "type": str(self.top_level_downstream_sequential.usage_key.block_type), - } + sequential_block_key = get_block_key_string( + self.top_level_downstream_sequential.usage_key + ) + assert unit.top_level_downstream_parent_key == sequential_block_key video = modulestore().get_item(self.top_level_downstream_video_key) # The sequential is the top-level parent for the video - assert video.top_level_downstream_parent_key == { - "id": str(self.top_level_downstream_sequential.usage_key.block_id), - "type": str(self.top_level_downstream_sequential.usage_key.block_type), - } + assert video.top_level_downstream_parent_key == sequential_block_key all_downstreams = self.client.get( "/api/contentstore/v2/downstreams/", @@ -599,6 +601,7 @@ def test_204(self, mock_decline_sync): @ddt.ddt class GetUpstreamViewTest( _BaseDownstreamViewTestMixin, + ImmediateOnCommitMixin, SharedModuleStoreTestCase, ): """ @@ -646,6 +649,8 @@ def test_200_single_upstream_container(self): self.assertDictEqual(data['ready_to_sync_children'][0], { 'name': html_block.display_name, 'upstream': str(self.html_lib_id_2), + 'block_type': 'html', + 'downstream_customized': [], 'id': str(html_block.usage_key), }) @@ -675,7 +680,7 @@ def test_200_all_downstreams_for_a_course(self): 'version_declined': None, 'version_synced': 1, 'top_level_parent_usage_key': None, - 'downstream_is_modified': False, + 'downstream_customized': [], }, { 'created': date_format, @@ -693,7 +698,7 @@ def test_200_all_downstreams_for_a_course(self): 'version_declined': None, 'version_synced': 1, 'top_level_parent_usage_key': None, - 'downstream_is_modified': False, + 'downstream_customized': [], }, { 'created': date_format, @@ -711,7 +716,7 @@ def test_200_all_downstreams_for_a_course(self): 'version_declined': None, 'version_synced': 1, 'top_level_parent_usage_key': str(self.top_level_downstream_unit.usage_key), - 'downstream_is_modified': False, + 'downstream_customized': [], }, { 'created': date_format, @@ -729,7 +734,7 @@ def test_200_all_downstreams_for_a_course(self): 'version_declined': None, 'version_synced': 1, 'top_level_parent_usage_key': str(self.top_level_downstream_chapter.usage_key), - 'downstream_is_modified': False, + 'downstream_customized': [], }, { 'created': date_format, @@ -747,7 +752,7 @@ def test_200_all_downstreams_for_a_course(self): 'version_declined': None, 'version_synced': 1, 'top_level_parent_usage_key': None, - 'downstream_is_modified': False, + 'downstream_customized': [], }, { 'created': date_format, @@ -765,7 +770,7 @@ def test_200_all_downstreams_for_a_course(self): 'version_declined': None, 'version_synced': 1, 'top_level_parent_usage_key': None, - 'downstream_is_modified': False, + 'downstream_customized': [], }, { 'created': date_format, @@ -783,7 +788,7 @@ def test_200_all_downstreams_for_a_course(self): 'version_declined': None, 'version_synced': 1, 'top_level_parent_usage_key': None, - 'downstream_is_modified': False, + 'downstream_customized': [], }, { 'created': date_format, @@ -801,7 +806,7 @@ def test_200_all_downstreams_for_a_course(self): 'version_declined': None, 'version_synced': 1, 'top_level_parent_usage_key': None, - 'downstream_is_modified': False, + 'downstream_customized': [], }, { 'created': date_format, @@ -819,7 +824,7 @@ def test_200_all_downstreams_for_a_course(self): 'version_declined': None, 'version_synced': 1, 'top_level_parent_usage_key': None, - 'downstream_is_modified': False, + 'downstream_customized': [], }, { 'created': date_format, @@ -837,7 +842,7 @@ def test_200_all_downstreams_for_a_course(self): 'version_declined': None, 'version_synced': 1, 'top_level_parent_usage_key': str(self.top_level_downstream_chapter.usage_key), - 'downstream_is_modified': False, + 'downstream_customized': [], }, { 'created': date_format, @@ -855,7 +860,7 @@ def test_200_all_downstreams_for_a_course(self): 'version_declined': None, 'version_synced': 1, 'top_level_parent_usage_key': str(self.top_level_downstream_chapter.usage_key), - 'downstream_is_modified': False, + 'downstream_customized': [], }, ] self.assertListEqual(data["results"], expected) @@ -895,7 +900,7 @@ def test_200_component_downstreams_for_a_course(self): 'version_declined': None, 'version_synced': 1, 'top_level_parent_usage_key': None, - 'downstream_is_modified': False, + 'downstream_customized': [], }, { 'created': date_format, @@ -913,7 +918,7 @@ def test_200_component_downstreams_for_a_course(self): 'version_declined': None, 'version_synced': 1, 'top_level_parent_usage_key': None, - 'downstream_is_modified': False, + 'downstream_customized': [], }, { 'created': date_format, @@ -931,7 +936,7 @@ def test_200_component_downstreams_for_a_course(self): 'version_declined': None, 'version_synced': 1, 'top_level_parent_usage_key': str(self.top_level_downstream_unit.usage_key), - 'downstream_is_modified': False, + 'downstream_customized': [], }, { 'created': date_format, @@ -949,7 +954,7 @@ def test_200_component_downstreams_for_a_course(self): 'version_declined': None, 'version_synced': 1, 'top_level_parent_usage_key': str(self.top_level_downstream_chapter.usage_key), - 'downstream_is_modified': False, + 'downstream_customized': [], }, ] self.assertListEqual(data["results"], expected) @@ -984,7 +989,7 @@ def test_200_container_downstreams_for_a_course(self): 'version_declined': None, 'version_synced': 1, 'top_level_parent_usage_key': None, - 'downstream_is_modified': False, + 'downstream_customized': [], }, { 'created': date_format, @@ -1002,7 +1007,7 @@ def test_200_container_downstreams_for_a_course(self): 'version_declined': None, 'version_synced': 1, 'top_level_parent_usage_key': None, - 'downstream_is_modified': False, + 'downstream_customized': [], }, { 'created': date_format, @@ -1020,7 +1025,7 @@ def test_200_container_downstreams_for_a_course(self): 'version_declined': None, 'version_synced': 1, 'top_level_parent_usage_key': None, - 'downstream_is_modified': False, + 'downstream_customized': [], }, { 'created': date_format, @@ -1038,7 +1043,7 @@ def test_200_container_downstreams_for_a_course(self): 'version_declined': None, 'version_synced': 1, 'top_level_parent_usage_key': None, - 'downstream_is_modified': False, + 'downstream_customized': [], }, { 'created': date_format, @@ -1056,7 +1061,7 @@ def test_200_container_downstreams_for_a_course(self): 'version_declined': None, 'version_synced': 1, 'top_level_parent_usage_key': None, - 'downstream_is_modified': False, + 'downstream_customized': [], }, { 'created': date_format, @@ -1074,7 +1079,7 @@ def test_200_container_downstreams_for_a_course(self): 'version_declined': None, 'version_synced': 1, 'top_level_parent_usage_key': str(self.top_level_downstream_chapter.usage_key), - 'downstream_is_modified': False, + 'downstream_customized': [], }, { 'created': date_format, @@ -1092,7 +1097,7 @@ def test_200_container_downstreams_for_a_course(self): 'version_declined': None, 'version_synced': 1, 'top_level_parent_usage_key': str(self.top_level_downstream_chapter.usage_key), - 'downstream_is_modified': False, + 'downstream_customized': [], }, ] self.assertListEqual(data["results"], expected) @@ -1192,7 +1197,7 @@ def test_200_get_ready_to_sync_top_level_parents_with_components(self): 'version_declined': None, 'version_synced': 1, 'top_level_parent_usage_key': None, - 'downstream_is_modified': False, + 'downstream_customized': [], }, { 'created': date_format, @@ -1210,7 +1215,7 @@ def test_200_get_ready_to_sync_top_level_parents_with_components(self): 'version_declined': None, 'version_synced': 1, 'top_level_parent_usage_key': None, - 'downstream_is_modified': False, + 'downstream_customized': [], }, { 'created': date_format, @@ -1228,7 +1233,7 @@ def test_200_get_ready_to_sync_top_level_parents_with_components(self): 'version_declined': None, 'version_synced': 1, 'top_level_parent_usage_key': None, - 'downstream_is_modified': False, + 'downstream_customized': [], }, { 'created': date_format, @@ -1246,11 +1251,9 @@ def test_200_get_ready_to_sync_top_level_parents_with_components(self): 'version_declined': None, 'version_synced': 1, 'top_level_parent_usage_key': None, - 'downstream_is_modified': False, + 'downstream_customized': [], }, ] - print(data["results"]) - print(expected) self.assertListEqual(data["results"], expected) def test_200_get_ready_to_sync_top_level_parents_with_containers(self): @@ -1293,7 +1296,7 @@ def test_200_get_ready_to_sync_top_level_parents_with_containers(self): 'version_declined': None, 'version_synced': 1, 'top_level_parent_usage_key': None, - 'downstream_is_modified': False, + 'downstream_customized': [], }, { 'created': date_format, @@ -1311,7 +1314,7 @@ def test_200_get_ready_to_sync_top_level_parents_with_containers(self): 'version_declined': None, 'version_synced': 1, 'top_level_parent_usage_key': None, - 'downstream_is_modified': False, + 'downstream_customized': [], }, { 'created': date_format, @@ -1329,7 +1332,7 @@ def test_200_get_ready_to_sync_top_level_parents_with_containers(self): 'version_declined': None, 'version_synced': 1, 'top_level_parent_usage_key': None, - 'downstream_is_modified': False, + 'downstream_customized': [], }, ] self.assertListEqual(data["results"], expected) @@ -1383,7 +1386,7 @@ def test_200_get_ready_to_sync_duplicated_top_level_parents(self): 'version_declined': None, 'version_synced': 1, 'top_level_parent_usage_key': None, - 'downstream_is_modified': False, + 'downstream_customized': [], }, { 'created': date_format, @@ -1401,7 +1404,7 @@ def test_200_get_ready_to_sync_duplicated_top_level_parents(self): 'version_declined': None, 'version_synced': 1, 'top_level_parent_usage_key': None, - 'downstream_is_modified': False, + 'downstream_customized': [], }, { 'created': date_format, @@ -1419,7 +1422,7 @@ def test_200_get_ready_to_sync_duplicated_top_level_parents(self): 'version_declined': None, 'version_synced': 1, 'top_level_parent_usage_key': None, - 'downstream_is_modified': False, + 'downstream_customized': [], }, ] self.assertListEqual(data["results"], expected) @@ -1427,6 +1430,7 @@ def test_200_get_ready_to_sync_duplicated_top_level_parents(self): class GetDownstreamSummaryViewTest( _BaseDownstreamViewTestMixin, + ImmediateOnCommitMixin, SharedModuleStoreTestCase, ): """ @@ -1504,3 +1508,109 @@ def test_200_summary(self): 'last_published_at': self.now.strftime('%Y-%m-%dT%H:%M:%S.%fZ'), }] self.assertListEqual(data, expected) + + +class GetDownstreamDeletedUpstream( + _BaseDownstreamViewTestMixin, + ImmediateOnCommitMixin, + SharedModuleStoreTestCase, +): + """ + Test that parent container is marked ready_to_sync when even when the only change is a deleted component under it + """ + def call_api( + self, + course_id: str | None = None, + ready_to_sync: bool | None = None, + upstream_key: str | None = None, + item_type: str | None = None, + use_top_level_parents: bool | None = None, + ): + data = {} + if course_id is not None: + data["course_id"] = str(course_id) + if ready_to_sync is not None: + data["ready_to_sync"] = str(ready_to_sync) + if upstream_key is not None: + data["upstream_key"] = str(upstream_key) + if item_type is not None: + data["item_type"] = str(item_type) + if use_top_level_parents is not None: + data["use_top_level_parents"] = str(use_top_level_parents) + return self.client.get("/api/contentstore/v2/downstreams/", data=data) + + def test_delete_component_should_be_ready_to_sync(self): + """ + Test deleting a component from library should mark the entire section container ready to sync + """ + # Create blocks + section_id = self._create_container(self.library_id, "section", "section-12", "Section 12")["id"] + subsection_id = self._create_container(self.library_id, "subsection", "subsection-12", "Subsection 12")["id"] + unit_id = self._create_container(self.library_id, "unit", "unit-12", "Unit 12")["id"] + video_id = self._add_block_to_library(self.library_id, "video", "video-bar-13")["id"] + section_key = ContainerKey.from_string(section_id) + subsection_key = ContainerKey.from_string(subsection_id) + unit_key = ContainerKey.from_string(unit_id) + video_key = LibraryUsageLocatorV2.from_string(video_id) + + # Set children + lib_api.update_container_children(section_key, [subsection_key], None) + lib_api.update_container_children(subsection_key, [unit_key], None) + lib_api.update_container_children(unit_key, [video_key], None) + self._publish_container(unit_id) + self._publish_container(subsection_id) + self._publish_container(section_id) + self._publish_library_block(video_id) + course = CourseFactory.create(display_name="Course New") + add_users(self.superuser, CourseStaffRole(course.id), self.course_user) + chapter = BlockFactory.create( + category='chapter', parent=course, upstream=section_id, upstream_version=2, + ) + sequential = BlockFactory.create( + category='sequential', + parent=chapter, + upstream=subsection_id, + upstream_version=2, + top_level_downstream_parent_key=get_block_key_string(chapter.usage_key), + ) + vertical = BlockFactory.create( + category='vertical', + parent=sequential, + upstream=unit_id, + upstream_version=2, + top_level_downstream_parent_key=get_block_key_string(chapter.usage_key), + ) + BlockFactory.create( + category='video', + parent=vertical, + upstream=video_id, + upstream_version=1, + top_level_downstream_parent_key=get_block_key_string(chapter.usage_key), + ) + self._delete_component(video_id) + self._publish_container(unit_id) + response = self.call_api(course_id=course.id, ready_to_sync=True, use_top_level_parents=True) + assert response.status_code == 200 + data = response.json()['results'] + assert len(data) == 1 + date_format = self.now.isoformat().split("+")[0] + 'Z' + expected_results = { + 'created': date_format, + 'downstream_context_key': str(course.id), + 'downstream_usage_key': str(chapter.usage_key), + 'downstream_customized': [], + 'id': 8, + 'ready_to_sync': False, + 'ready_to_sync_from_children': True, + 'top_level_parent_usage_key': None, + 'updated': date_format, + 'upstream_context_key': self.library_id, + 'upstream_context_title': self.library_title, + 'upstream_key': section_id, + 'upstream_type': 'container', + 'upstream_version': 2, + 'version_declined': None, + 'version_synced': 2, + } + + self.assertDictEqual(data[0], expected_results) diff --git a/cms/djangoapps/contentstore/signals/handlers.py b/cms/djangoapps/contentstore/signals/handlers.py index ebf47f527f5f..e28cbf313acb 100644 --- a/cms/djangoapps/contentstore/signals/handlers.py +++ b/cms/djangoapps/contentstore/signals/handlers.py @@ -221,6 +221,7 @@ def handle_item_deleted(**kwargs): id_list.add(block.location) ComponentLink.objects.filter(downstream_usage_key__in=id_list).delete() + ContainerLink.objects.filter(downstream_usage_key__in=id_list).delete() @receiver(GRADING_POLICY_CHANGED) diff --git a/cms/djangoapps/contentstore/tasks.py b/cms/djangoapps/contentstore/tasks.py index 71b86acca201..13c72ff3391a 100644 --- a/cms/djangoapps/contentstore/tasks.py +++ b/cms/djangoapps/contentstore/tasks.py @@ -92,6 +92,7 @@ from xmodule.modulestore.xml_exporter import export_course_to_xml, export_library_to_xml from xmodule.modulestore.xml_importer import CourseImportException, import_course_from_xml, import_library_from_xml from xmodule.tabs import StaticTab +from xmodule.util.keys import BlockKey from .models import ComponentLink, ContainerLink, LearningContextLinksStatus, LearningContextLinksStatusChoices from .outlines import update_outline_from_modulestore @@ -1649,10 +1650,11 @@ def handle_create_xblock_upstream_link(usage_key): if not xblock.upstream or not xblock.upstream_version: return if xblock.top_level_downstream_parent_key is not None: + block_key = BlockKey.from_string(xblock.top_level_downstream_parent_key) top_level_parent_usage_key = BlockUsageLocator( xblock.course_id, - xblock.top_level_downstream_parent_key.get('type'), - xblock.top_level_downstream_parent_key.get('id'), + block_key.type, + block_key.id, ) try: ContainerLink.get_by_downstream_usage_key(top_level_parent_usage_key) @@ -1675,7 +1677,7 @@ def handle_update_xblock_upstream_link(usage_key): except (ItemNotFoundError, InvalidKeyError): LOGGER.exception(f'Could not find item for given usage_key: {usage_key}') return - if not xblock.upstream or not xblock.upstream_version: + if not xblock.upstream or xblock.upstream_version is None: return create_or_update_xblock_upstream_link(xblock) diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index 8b6aa6d2bba5..a8721d629c76 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -16,11 +16,11 @@ from uuid import uuid4 import ddt -import lxml.html from django.conf import settings from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user from django.test import TestCase from django.test.utils import override_settings +from django.urls import reverse from edx_toggles.toggles.testutils import override_waffle_switch, override_waffle_flag from edxval.api import create_video, get_videos_for_course from fs.osfs import OSFS @@ -1388,17 +1388,6 @@ def assert_course_permission_denied(self): resp = self.client.ajax_post('/course/', self.course_data) self.assertEqual(resp.status_code, 403) - @override_waffle_flag(toggles.LEGACY_STUDIO_HOME, True) - def test_course_index_view_with_no_courses(self): - """Test viewing the index page with no courses""" - resp = self.client.get_html('/home/') - self.assertContains( - resp, - f'

{settings.STUDIO_SHORT_NAME} Home

', - status_code=200, - html=True - ) - def test_course_factory(self): """Test that the course factory works correctly.""" course = CourseFactory.create() @@ -1410,33 +1399,6 @@ def test_item_factory(self): item = BlockFactory.create(parent_location=course.location) self.assertIsInstance(item, SequenceBlock) - @override_waffle_flag(toggles.LEGACY_STUDIO_COURSE_OUTLINE, True) - def test_course_overview_view_with_course(self): - """Test viewing the course overview page with an existing course""" - course = CourseFactory.create() - resp = self._show_course_overview(course.id) - - # course_handler raise 404 for old mongo course - if course.id.deprecated: - self.assertEqual(resp.status_code, 404) - return - - assets_url = reverse_course_url( - 'assets_handler', - course.location.course_key - ) - - self.assertContains( - resp, - '
'.format( # lint-amnesty, pylint: disable=line-too-long - locator=str(course.location), - course_key=str(course.id), - assets_url=assets_url, - ), - status_code=200, - html=True - ) - def test_create_block(self): """Test creating a new xblock instance.""" course = CourseFactory.create() @@ -1510,8 +1472,7 @@ def test_get_html(handler): ) course_key = course_items[0].id - with override_waffle_flag(toggles.LEGACY_STUDIO_COURSE_OUTLINE, True): - resp = self._show_course_overview(course_key) + resp = self._show_course_overview(course_key) # course_handler raise 404 for old mongo course if course_key.deprecated: @@ -1530,8 +1491,6 @@ def test_get_html(handler): test_get_html('course_team_handler') with override_waffle_flag(toggles.LEGACY_STUDIO_UPDATES, True): test_get_html('course_info_handler') - with override_waffle_flag(toggles.LEGACY_STUDIO_FILES_UPLOADS, True): - test_get_html('assets_handler') with override_waffle_flag(toggles.LEGACY_STUDIO_CUSTOM_PAGES, True): test_get_html('tabs_handler') with override_waffle_flag(toggles.LEGACY_STUDIO_SCHEDULE_DETAILS, True): @@ -1755,7 +1714,8 @@ def _show_course_overview(self, course_key): """ Show the course overview page. """ - resp = self.client.get_html(get_url('course_handler', course_key, 'course_key_string')) + resp = self.client.get(get_url('course_handler', course_key, 'course_key_string'), + content_type='application/json') return resp def test_wiki_slug(self): @@ -1879,17 +1839,21 @@ def assertInCourseListing(self, course_key): """ Asserts that the given course key is NOT in the unsucceeded course action section of the html. """ - with override_waffle_flag(toggles.LEGACY_STUDIO_HOME, True): - course_listing = lxml.html.fromstring(self.client.get_html('/home/').content) - self.assertEqual(len(self.get_unsucceeded_course_action_elements(course_listing, course_key)), 0) + response = self.client.get(reverse('cms.djangoapps.contentstore:v2:courses')) + assert str(course_key) not in [ + course["course_key"] + for course in response.json()["results"]["in_process_course_actions"] + ] def assertInUnsucceededCourseActions(self, course_key): """ Asserts that the given course key is in the unsucceeded course action section of the html. """ - with override_waffle_flag(toggles.LEGACY_STUDIO_HOME, True): - course_listing = lxml.html.fromstring(self.client.get_html('/home/').content) - self.assertEqual(len(self.get_unsucceeded_course_action_elements(course_listing, course_key)), 1) + response = self.client.get(reverse('cms.djangoapps.contentstore:v2:courses')) + assert str(course_key) in [ + course["course_key"] + for course in response.json()["results"]["in_process_course_actions"] + ] def verify_rerun_course(self, source_course_key, destination_course_key, destination_display_name): """ diff --git a/cms/djangoapps/contentstore/tests/test_course_listing.py b/cms/djangoapps/contentstore/tests/test_course_listing.py index a2b6f07d15ef..990eff83c922 100644 --- a/cms/djangoapps/contentstore/tests/test_course_listing.py +++ b/cms/djangoapps/contentstore/tests/test_course_listing.py @@ -8,12 +8,9 @@ import ddt from ccx_keys.locator import CCXLocator -from django.conf import settings from django.test import RequestFactory -from edx_toggles.toggles.testutils import override_waffle_flag from opaque_keys.edx.locations import CourseLocator -from cms.djangoapps.contentstore import toggles from cms.djangoapps.contentstore.tests.utils import AjaxEnabledTestClient from cms.djangoapps.contentstore.utils import delete_course from cms.djangoapps.contentstore.views.course import ( @@ -91,15 +88,6 @@ def tearDown(self): self.client.logout() ModuleStoreTestCase.tearDown(self) # pylint: disable=non-parent-method-called - @override_waffle_flag(toggles.LEGACY_STUDIO_HOME, True) - def test_empty_course_listing(self): - """ - Test on empty course listing, studio name is properly displayed - """ - message = f"Are you staff on an existing {settings.STUDIO_SHORT_NAME} course?" - response = self.client.get('/home') - self.assertContains(response, message) - def test_get_course_list(self): """ Test getting courses with new access group format e.g. 'instructor_edx.course.run' diff --git a/cms/djangoapps/contentstore/tests/test_course_settings.py b/cms/djangoapps/contentstore/tests/test_course_settings.py index 08d858c55062..9afba26b32e5 100644 --- a/cms/djangoapps/contentstore/tests/test_course_settings.py +++ b/cms/djangoapps/contentstore/tests/test_course_settings.py @@ -168,7 +168,6 @@ def test_discussion_fields_available(self, is_pages_and_resources_enabled, @override_waffle_flag(toggles.LEGACY_STUDIO_EXPORT, True) @override_waffle_flag(toggles.LEGACY_STUDIO_COURSE_TEAM, True) @override_waffle_flag(toggles.LEGACY_STUDIO_UPDATES, True) - @override_waffle_flag(toggles.LEGACY_STUDIO_FILES_UPLOADS, True) @override_waffle_flag(toggles.LEGACY_STUDIO_CUSTOM_PAGES, True) @override_waffle_flag(toggles.LEGACY_STUDIO_SCHEDULE_DETAILS, True) @override_waffle_flag(toggles.LEGACY_STUDIO_GRADING, True) @@ -188,7 +187,6 @@ def test_disable_advanced_settings_feature(self, disable_advanced_settings): 'export_handler', 'course_team_handler', 'course_info_handler', - 'assets_handler', 'tabs_handler', 'settings_handler', 'grading_handler', @@ -501,7 +499,11 @@ def test_entrance_exam_created_updated_and_deleted_successfully(self): course = modulestore().get_course(self.course.id) self.assertEqual(response.status_code, 200) self.assertFalse(course.entrance_exam_enabled) - self.assertEqual(course.entrance_exam_minimum_score_pct, None) + entrance_exam_minimum_score_pct = float(settings.ENTRANCE_EXAM_MIN_SCORE_PCT) + if entrance_exam_minimum_score_pct.is_integer(): + entrance_exam_minimum_score_pct = entrance_exam_minimum_score_pct / 100 + + self.assertEqual(course.entrance_exam_minimum_score_pct, entrance_exam_minimum_score_pct) self.assertFalse(milestones_helpers.any_unfulfilled_milestones(self.course.id, self.user.id), msg='The entrance exam should not be required anymore') diff --git a/cms/djangoapps/contentstore/tests/test_i18n.py b/cms/djangoapps/contentstore/tests/test_i18n.py index 6201253babf4..3ee991493196 100644 --- a/cms/djangoapps/contentstore/tests/test_i18n.py +++ b/cms/djangoapps/contentstore/tests/test_i18n.py @@ -2,10 +2,9 @@ Tests for validate Internationalization and XBlock i18n service. """ import gettext -from unittest import mock, skip +from unittest import mock from django.utils import translation -from edx_toggles.toggles.testutils import override_waffle_flag from django.utils.translation import get_language from xblock.core import XBlock @@ -14,10 +13,7 @@ from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory from xmodule.tests.test_export import PureXBlock -from cms.djangoapps.contentstore import toggles -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 class FakeTranslations(XBlockI18nService): @@ -166,101 +162,3 @@ def test_i18n_service_callable(self): Test: i18n service should be callable in studio. """ self.assertTrue(callable(self.block.runtime._services.get('i18n'))) # pylint: disable=protected-access - - -class InternationalizationTest(ModuleStoreTestCase): - """ - Tests to validate Internationalization. - """ - - CREATE_USER = False - - def setUp(self): - """ - These tests need a user in the DB so that the django Test Client - can log them in. - They inherit from the ModuleStoreTestCase class so that the mongodb collection - will be cleared out before each test case execution and deleted - afterwards. - """ - super().setUp() - - self.uname = 'testuser' - self.email = 'test+courses@edx.org' - self.password = self.TEST_PASSWORD - - # Create the use so we can log them in. - self.user = UserFactory.create(username=self.uname, email=self.email, password=self.password) - - # Note that we do not actually need to do anything - # for registration if we directly mark them active. - self.user.is_active = True - # Staff has access to view all courses - self.user.is_staff = True - self.user.save() - - self.course_data = { - 'org': 'MITx', - 'number': '999', - 'display_name': 'Robot Super Course', - } - - @override_waffle_flag(toggles.LEGACY_STUDIO_HOME, True) - def test_course_plain_english(self): - """Test viewing the index page with no courses""" - self.client = AjaxEnabledTestClient() # lint-amnesty, pylint: disable=attribute-defined-outside-init - self.client.login(username=self.uname, password=self.password) - - resp = self.client.get_html('/home/') - self.assertContains(resp, - '

𝓢𝓽𝓾𝓭𝓲𝓸 Home

', - status_code=200, - html=True) - - @override_waffle_flag(toggles.LEGACY_STUDIO_HOME, True) - def test_course_explicit_english(self): - """Test viewing the index page with no courses""" - self.client = AjaxEnabledTestClient() # lint-amnesty, pylint: disable=attribute-defined-outside-init - self.client.login(username=self.uname, password=self.password) - - resp = self.client.get_html( - '/home/', - {}, - HTTP_ACCEPT_LANGUAGE='en', - ) - - self.assertContains(resp, - '

𝓢𝓽𝓾𝓭𝓲𝓸 Home

', - status_code=200, - html=True) - - # **** - # NOTE: - # **** - # - # This test will break when we replace this fake 'test' language - # with actual Esperanto. This test will need to be updated with - # actual Esperanto at that time. - # Test temporarily disable since it depends on creation of dummy strings - @skip - def test_course_with_accents(self): - """Test viewing the index page with no courses""" - self.client = AjaxEnabledTestClient() # lint-amnesty, pylint: disable=attribute-defined-outside-init - self.client.login(username=self.uname, password=self.password) - - resp = self.client.get_html( - '/home/', - {}, - HTTP_ACCEPT_LANGUAGE='eo' - ) - - TEST_STRING = ( - '

' - 'My \xc7\xf6\xfcrs\xe9s L#' - '

' - ) - - self.assertContains(resp, - TEST_STRING, - status_code=200, - html=True) diff --git a/cms/djangoapps/contentstore/tests/test_upstream_downstream_links.py b/cms/djangoapps/contentstore/tests/test_upstream_downstream_links.py index 90fec8471651..5c3ba8386480 100644 --- a/cms/djangoapps/contentstore/tests/test_upstream_downstream_links.py +++ b/cms/djangoapps/contentstore/tests/test_upstream_downstream_links.py @@ -14,7 +14,7 @@ from common.djangoapps.student.tests.factories import UserFactory from openedx.core.djangolib.testing.utils import skip_unless_cms -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, ImmediateOnCommitMixin from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory from ..models import ContainerLink, LearningContextLinksStatus, LearningContextLinksStatusChoices, ComponentLink @@ -265,7 +265,12 @@ def test_call_for_nonexistent_course(self): @skip_unless_cms -class TestUpstreamLinksEvents(ModuleStoreTestCase, OpenEdxEventsTestMixin, BaseUpstreamLinksHelpers): +class TestUpstreamLinksEvents( + ImmediateOnCommitMixin, + ModuleStoreTestCase, + OpenEdxEventsTestMixin, + BaseUpstreamLinksHelpers, +): """ Test signals related to managing upstream->downstream links. """ diff --git a/cms/djangoapps/contentstore/tests/tests.py b/cms/djangoapps/contentstore/tests/tests.py index 40b0f8ad4cbf..d151b1d58526 100644 --- a/cms/djangoapps/contentstore/tests/tests.py +++ b/cms/djangoapps/contentstore/tests/tests.py @@ -11,7 +11,7 @@ import datetime import time from unittest import mock -from urllib.parse import quote_plus +from urllib.parse import quote_plus, unquote from ddt import data, ddt, unpack from django.conf import settings @@ -24,6 +24,7 @@ from cms.djangoapps.contentstore import toggles from cms.djangoapps.contentstore.tests.test_course_settings import CourseTestCase from cms.djangoapps.contentstore.tests.utils import AjaxEnabledTestClient, parse_json, registration, user +from cms.djangoapps.contentstore.utils import get_studio_home_url from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, pylint: disable=wrong-import-order @@ -114,12 +115,6 @@ def setUp(self): # clear the cache so ratelimiting won't affect these tests cache.clear() - def check_page_get(self, url, expected): - resp = self.client.get_html(url) - self.assertEqual(resp.status_code, expected) - return resp - - @override_waffle_flag(toggles.LEGACY_STUDIO_HOME, True) def test_private_pages_auth(self): """Make sure pages that do require login work.""" auth_pages = ( @@ -143,7 +138,9 @@ def test_private_pages_auth(self): print('Not logged in') for page in auth_pages: print(f"Checking '{page}'") - self.check_page_get(page, expected=302) + resp = self.client.get_html(page) + assert resp.status_code == 302 + assert resp.url == unquote(reverse("login", query={"next": page})) # Logged in should work. self.login(self.email, self.pw) @@ -151,10 +148,11 @@ def test_private_pages_auth(self): print('Logged in') for page in simple_auth_pages: print(f"Checking '{page}'") - self.check_page_get(page, expected=200) + resp = self.client.get_html(page) + assert resp.status_code == 302 + assert resp.url == get_studio_home_url() @override_settings(SESSION_INACTIVITY_TIMEOUT_IN_SECONDS=1) - @override_waffle_flag(toggles.LEGACY_STUDIO_HOME, True) def test_inactive_session_timeout(self): """ Verify that an inactive session times out and redirects to the @@ -168,7 +166,8 @@ def test_inactive_session_timeout(self): # make sure we can access courseware immediately course_url = '/home/' resp = self.client.get_html(course_url) - self.assertEqual(resp.status_code, 200) + assert resp.status_code == 302 + assert resp.url == get_studio_home_url() # then wait a bit and see if we get timed out time.sleep(2) diff --git a/cms/djangoapps/contentstore/toggles.py b/cms/djangoapps/contentstore/toggles.py index c3dba4a6f4a4..7c7b743b1074 100644 --- a/cms/djangoapps/contentstore/toggles.py +++ b/cms/djangoapps/contentstore/toggles.py @@ -86,25 +86,6 @@ def exam_setting_view_enabled(course_key): return not LEGACY_STUDIO_EXAM_SETTINGS.is_enabled(course_key) -# .. toggle_name: legacy_studio.text_editor -# .. toggle_implementation: WaffleFlag -# .. toggle_default: False -# .. toggle_description: Temporarily fall back to the old Text component (a.k.a. html block) editor. -# .. toggle_use_cases: temporary -# .. toggle_creation_date: 2025-03-14 -# .. toggle_target_removal_date: 2025-09-14 -# .. toggle_tickets: https://github.com/openedx/edx-platform/issues/36275 -# .. toggle_warning: In Ulmo, this toggle will be removed. Only the new (React-based) experience will be available. -LEGACY_STUDIO_TEXT_EDITOR = CourseWaffleFlag("legacy_studio.text_editor", __name__) - - -def use_new_text_editor(course_key): - """ - Returns a boolean = true if new text editor is enabled - """ - return not LEGACY_STUDIO_TEXT_EDITOR.is_enabled(course_key) - - # .. toggle_name: legacy_studio.video_editor # .. toggle_implementation: WaffleFlag # .. toggle_default: False @@ -181,25 +162,6 @@ def individualize_anonymous_user_id(course_id): return INDIVIDUALIZE_ANONYMOUS_USER_ID.is_enabled(course_id) -# .. toggle_name: legacy_studio.home -# .. toggle_implementation: WaffleFlag -# .. toggle_default: False -# .. toggle_description: Temporarily fall back to the old Studio logged-in landing page. -# .. toggle_use_cases: temporary -# .. toggle_creation_date: 2025-03-14 -# .. toggle_target_removal_date: 2025-09-14 -# .. toggle_tickets: https://github.com/openedx/edx-platform/issues/36275 -# .. toggle_warning: In Ulmo, this toggle will be removed. Only the new (React-based) experience will be available. -LEGACY_STUDIO_HOME = WaffleFlag('legacy_studio.home', __name__) - - -def use_new_home_page(): - """ - Returns a boolean if new studio home page mfe is enabled - """ - return not LEGACY_STUDIO_HOME.is_enabled() - - # .. toggle_name: legacy_studio.custom_pages # .. toggle_implementation: WaffleFlag # .. toggle_default: False @@ -351,25 +313,6 @@ def use_new_export_page(course_key): return not LEGACY_STUDIO_EXPORT.is_enabled(course_key) -# .. toggle_name: legacy_studio.files_uploads -# .. toggle_implementation: WaffleFlag -# .. toggle_default: False -# .. toggle_description: Temporarily fall back to the old Studio Files & Uploads page. -# .. toggle_use_cases: temporary -# .. toggle_creation_date: 2025-03-14 -# .. toggle_target_removal_date: 2025-09-14 -# .. toggle_tickets: https://github.com/openedx/edx-platform/issues/36275 -# .. toggle_warning: In Ulmo, this toggle will be removed. Only the new (React-based) experience will be available. -LEGACY_STUDIO_FILES_UPLOADS = CourseWaffleFlag('legacy_studio.files_uploads', __name__) - - -def use_new_files_uploads_page(course_key): - """ - Returns a boolean if new studio files and uploads mfe is enabled - """ - return not LEGACY_STUDIO_FILES_UPLOADS.is_enabled(course_key) - - # .. toggle_name: contentstore.new_studio_mfe.use_new_video_uploads_page # .. toggle_implementation: CourseWaffleFlag # .. toggle_default: False @@ -402,13 +345,6 @@ def use_new_video_uploads_page(course_key): LEGACY_STUDIO_COURSE_OUTLINE = CourseWaffleFlag('legacy_studio.course_outline', __name__) -def use_new_course_outline_page(course_key): - """ - Returns a boolean if new studio course outline mfe is enabled - """ - return not LEGACY_STUDIO_COURSE_OUTLINE.is_enabled(course_key) - - # .. toggle_name: legacy_studio.unit_editor # .. toggle_implementation: WaffleFlag # .. toggle_default: False diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py index 3ca1d20bf564..5506d8c33e41 100644 --- a/cms/djangoapps/contentstore/utils.py +++ b/cms/djangoapps/contentstore/utils.py @@ -15,7 +15,7 @@ from bs4 import BeautifulSoup from django.conf import settings -from django.core.exceptions import ObjectDoesNotExist, ValidationError +from django.core.exceptions import ImproperlyConfigured, ObjectDoesNotExist, ValidationError from django.urls import reverse from django.utils import translation from django.utils.text import Truncator @@ -43,26 +43,22 @@ split_library_view_on_dashboard, use_new_advanced_settings_page, use_new_certificates_page, - use_new_course_outline_page, use_new_course_team_page, use_new_custom_pages, use_new_export_page, - use_new_files_uploads_page, use_new_grading_page, use_new_group_configurations_page, - use_new_home_page, use_new_import_page, use_new_schedule_details_page, - use_new_text_editor, use_new_textbooks_page, use_new_unit_page, use_new_updates_page, - use_new_video_editor, use_new_video_uploads_page, ) from cms.djangoapps.models.settings.course_grading import CourseGradingModel from cms.djangoapps.models.settings.course_metadata import CourseMetadata -from cms.djangoapps.modulestore_migrator.api import get_migration_info +from cms.djangoapps.modulestore_migrator import api as migrator_api +from cms.djangoapps.modulestore_migrator.data import ModulestoreMigration from common.djangoapps.course_action_state.managers import CourseActionStateItemNotFoundError from common.djangoapps.course_action_state.models import CourseRerunState, CourseRerunUIStateManager from common.djangoapps.course_modes.models import CourseMode @@ -117,6 +113,7 @@ get_all_partitions_for_course, # lint-amnesty, pylint: disable=wrong-import-order ) from xmodule.services import ConfigurationService, SettingsService, TeamsConfigurationService +from xmodule.util.keys import BlockKey from .models import ComponentLink, ContainerLink @@ -288,11 +285,10 @@ def get_editor_page_base_url(course_locator) -> str: Gets course authoring microfrontend URL for links to the new base editors """ editor_url = None - if use_new_text_editor(course_locator) or use_new_video_editor(course_locator): - mfe_base_url = get_course_authoring_url(course_locator) - course_mfe_url = f'{mfe_base_url}/course/{course_locator}/editor' - if mfe_base_url: - editor_url = course_mfe_url + mfe_base_url = get_course_authoring_url(course_locator) + course_mfe_url = f'{mfe_base_url}/course/{course_locator}/editor' + if mfe_base_url: + editor_url = course_mfe_url return editor_url @@ -300,12 +296,15 @@ def get_studio_home_url(): """ Gets course authoring microfrontend URL for Studio Home view. """ - studio_home_url = None - if use_new_home_page(): - mfe_base_url = settings.COURSE_AUTHORING_MICROFRONTEND_URL - if mfe_base_url: - studio_home_url = f'{mfe_base_url}/home' - return studio_home_url + mfe_base_url = settings.COURSE_AUTHORING_MICROFRONTEND_URL + if mfe_base_url: + studio_home_url = f'{mfe_base_url}/home' + return studio_home_url + + raise ImproperlyConfigured( + "The COURSE_AUTHORING_MICROFRONTEND_URL must be configured. " + "Please set it to the base url for your authoring MFE." + ) def get_schedule_details_url(course_locator) -> str: @@ -417,11 +416,10 @@ def get_files_uploads_url(course_locator) -> str: Gets course authoring microfrontend URL for files and uploads page view. """ files_uploads_url = None - if use_new_files_uploads_page(course_locator): - mfe_base_url = get_course_authoring_url(course_locator) - course_mfe_url = f'{mfe_base_url}/course/{course_locator}/assets' - if mfe_base_url: - files_uploads_url = course_mfe_url + mfe_base_url = get_course_authoring_url(course_locator) + course_mfe_url = f'{mfe_base_url}/course/{course_locator}/assets' + if mfe_base_url: + files_uploads_url = course_mfe_url return files_uploads_url @@ -443,13 +441,12 @@ def get_course_outline_url(course_locator, block_to_show=None) -> str: Gets course authoring microfrontend URL for course oultine page view. """ course_outline_url = None - if use_new_course_outline_page(course_locator): - mfe_base_url = get_course_authoring_url(course_locator) - course_mfe_url = f'{mfe_base_url}/course/{course_locator}' - if block_to_show: - course_mfe_url += f'?show={quote_plus(block_to_show)}' - if mfe_base_url: - course_outline_url = course_mfe_url + mfe_base_url = get_course_authoring_url(course_locator) + course_mfe_url = f'{mfe_base_url}/course/{course_locator}' + if block_to_show: + course_mfe_url += f'?show={quote_plus(block_to_show)}' + if mfe_base_url: + course_outline_url = course_mfe_url return course_outline_url @@ -1582,13 +1579,12 @@ def request_response_format_is_json(request, response_format): def get_library_context(request, request_is_json=False): """ - Utils is used to get context of course home library tab. - It is used for both DRF and django views. + Utils is used to get context of course home library tab. Returned in DRF view. """ from cms.djangoapps.contentstore.views.course import ( _accessible_libraries_iter, - _format_library_for_view, _get_course_creator_status, + format_library_for_view, get_allowed_organizations, get_allowed_organizations_for_libraries, user_can_create_organizations, @@ -1600,21 +1596,25 @@ def get_library_context(request, request_is_json=False): user_can_create_library, ) + is_migrated: bool | None # None means: do not filter on is_migrated + if (is_migrated_param := request.GET.get('is_migrated')) is not None: + is_migrated = BooleanField().to_internal_value(is_migrated_param) + else: + is_migrated = None libraries = list(_accessible_libraries_iter(request.user) if libraries_v1_enabled() else []) - library_keys = [lib.location.library_key for lib in libraries] - migration_info = get_migration_info(library_keys) - is_migrated_filter = request.GET.get('is_migrated', None) + migration_info: dict[LibraryLocator, ModulestoreMigration | None] = { + lib.id: migrator_api.get_forwarding(lib.id) + for lib in libraries + } data = { 'libraries': [ - _format_library_for_view( + format_library_for_view( lib, request, - migrated_to=migration_info.get(lib.location.library_key) + migration=migration_info[lib.id], ) for lib in libraries - if is_migrated_filter is None or ( - BooleanField().to_internal_value(is_migrated_filter) == (lib.location.library_key in migration_info) - ) + if is_migrated is None or is_migrated == bool(migration_info[lib.id]) ] } @@ -1723,8 +1723,7 @@ def format_in_process_course_view(uca): def get_home_context(request, no_course=False): """ - Utils is used to get context of course home. - It is used for both DRF and django views. + Utils is used to get context of course home. Returned by DRF view. """ from cms.djangoapps.contentstore.views.course import ( @@ -2411,10 +2410,11 @@ def _create_or_update_component_link(created: datetime | None, xblock): top_level_parent_usage_key = None if xblock.top_level_downstream_parent_key is not None: + block_key = BlockKey.from_string(xblock.top_level_downstream_parent_key) top_level_parent_usage_key = BlockUsageLocator( xblock.usage_key.course_key, - xblock.top_level_downstream_parent_key.get('type'), - xblock.top_level_downstream_parent_key.get('id'), + block_key.type, + block_key.id, ) ComponentLink.update_or_create( @@ -2426,7 +2426,7 @@ def _create_or_update_component_link(created: datetime | None, xblock): top_level_parent_usage_key=top_level_parent_usage_key, version_synced=xblock.upstream_version, version_declined=xblock.upstream_version_declined, - downstream_is_modified=len(getattr(xblock, "downstream_customized", [])) > 0, + downstream_customized=getattr(xblock, "downstream_customized", []), created=created, ) @@ -2444,10 +2444,11 @@ def _create_or_update_container_link(created: datetime | None, xblock): top_level_parent_usage_key = None if xblock.top_level_downstream_parent_key is not None: + block_key = BlockKey.from_string(xblock.top_level_downstream_parent_key) top_level_parent_usage_key = BlockUsageLocator( xblock.usage_key.course_key, - xblock.top_level_downstream_parent_key.get('type'), - xblock.top_level_downstream_parent_key.get('id'), + block_key.type, + block_key.id, ) ContainerLink.update_or_create( @@ -2459,7 +2460,7 @@ def _create_or_update_container_link(created: datetime | None, xblock): version_synced=xblock.upstream_version, top_level_parent_usage_key=top_level_parent_usage_key, version_declined=xblock.upstream_version_declined, - downstream_is_modified=len(getattr(xblock, "downstream_customized", [])) > 0, + downstream_customized=getattr(xblock, "downstream_customized", []), created=created, ) diff --git a/cms/djangoapps/contentstore/views/certificate_manager.py b/cms/djangoapps/contentstore/views/certificate_manager.py index 429950477fdd..081afdcc0dd7 100644 --- a/cms/djangoapps/contentstore/views/certificate_manager.py +++ b/cms/djangoapps/contentstore/views/certificate_manager.py @@ -121,7 +121,7 @@ def is_activated(course): along with the certificates. """ is_active = False - certificates = None + certificates = [] if settings.FEATURES.get('CERTIFICATES_HTML_VIEW', False): certificates = CertificateManager.get_certificates(course) # we are assuming only one certificate in certificates collection. diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index a93a35cf1d3c..681ea5f9fe9b 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -7,14 +7,19 @@ import random import re import string -from typing import Dict, NamedTuple, Optional +from typing import Dict import django.utils from ccx_keys.locator import CCXLocator from django.conf import settings 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.core.exceptions import ( + FieldError, + ImproperlyConfigured, + PermissionDenied, + ValidationError as DjangoValidationError, +) from django.db.models import QuerySet from django.http import Http404, HttpResponse, HttpResponseBadRequest, HttpResponseNotFound from django.shortcuts import redirect @@ -39,6 +44,7 @@ from cms.djangoapps.models.settings.course_grading import CourseGradingModel from cms.djangoapps.models.settings.course_metadata import CourseMetadata from cms.djangoapps.models.settings.encoder import CourseSettingsEncoder +from cms.djangoapps.modulestore_migrator.data import ModulestoreMigration from cms.djangoapps.contentstore.api.views.utils import get_bool_param from common.djangoapps.course_action_state.managers import CourseActionStateItemNotFoundError from common.djangoapps.course_action_state.models import CourseRerunState, CourseRerunUIStateManager @@ -86,8 +92,6 @@ from ..tasks import rerun_course as rerun_course_task from ..toggles import ( default_enable_flexible_peer_openassessments, - use_new_course_outline_page, - use_new_home_page, use_new_updates_page, use_new_advanced_settings_page, use_new_grading_page, @@ -99,15 +103,12 @@ add_instructor, get_advanced_settings_url, get_course_grading, - get_course_index_context, get_course_outline_url, get_course_rerun_context, get_course_settings, get_grading_url, get_group_configurations_context, get_group_configurations_url, - get_home_context, - get_library_context, get_lms_link_for_item, get_proctored_exam_settings_url, get_schedule_details_url, @@ -655,11 +656,7 @@ 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()) - - home_context = get_home_context(request) - return render_to_response('index.html', home_context) + return redirect(get_studio_home_url()) @login_required @@ -668,15 +665,28 @@ def library_listing(request): """ List all Libraries available to the logged in user """ - data = get_library_context(request) - return render_to_response('index.html', data) + mfe_base_url = settings.COURSE_AUTHORING_MICROFRONTEND_URL + if mfe_base_url: + return redirect(f'{mfe_base_url}/libraries') + raise ImproperlyConfigured( + "The COURSE_AUTHORING_MICROFRONTEND_URL must be configured. " + "Please set it to the base url for your authoring MFE." + ) -def _format_library_for_view(library, request, migrated_to: Optional[NamedTuple]): + +def format_library_for_view(library, request, migration: ModulestoreMigration | None): """ Return a dict of the data which the view requires for each library """ - + migration_info = {} + if migration: + migration_info = { + 'migrated_to_key': migration.target_key, + 'migrated_to_title': migration.target_title, + 'migrated_to_collection_key': migration.target_collection_slug, + 'migrated_to_collection_title': migration.target_collection_title, + } return { 'display_name': library.display_name, 'library_key': str(library.location.library_key), @@ -684,7 +694,8 @@ def _format_library_for_view(library, request, migrated_to: Optional[NamedTuple] 'org': library.display_org_with_default, 'number': library.display_number_with_default, 'can_edit': has_studio_write_access(request.user, library.location.library_key), - **(migrated_to._asdict() if migrated_to is not None else {}), + 'is_migrated': migration is not None, + **migration_info, } @@ -739,18 +750,8 @@ def course_index(request, course_key): org, course, name: Attributes of the Location for the item to edit """ - if use_new_course_outline_page(course_key): - block_to_show = request.GET.get("show") - return redirect(get_course_outline_url(course_key, block_to_show)) - with modulestore().bulk_operations(course_key): - # A depth of None implies the whole course. The course outline needs this in order to compute has_changes. - # A unit may not have a draft version, but one of its components could, and hence the unit itself has changes. - course_block = get_course_and_check_access(course_key, request.user, depth=None) - if not course_block: - raise Http404 - # should be under bulk_operations if course_block is passed - course_index_context = get_course_index_context(request, course_key, course_block) - return render_to_response('course_outline.html', course_index_context) + block_to_show = request.GET.get("show") + return redirect(get_course_outline_url(course_key, block_to_show)) @function_trace('get_courses_accessible_to_user') @@ -1851,12 +1852,20 @@ def get_allowed_organizations_for_libraries(user): """ Helper method for returning the list of organizations for which the user is allowed to create libraries. """ + organizations_set = set() + + # This allows org-level staff to create libraries. We should re-evaluate + # whether this is necessary and try to normalize course and library creation + # authorization behavior. if settings.FEATURES.get('ENABLE_ORGANIZATION_STAFF_ACCESS_FOR_CONTENT_LIBRARIES', False): - return get_organizations_for_non_course_creators(user) - elif settings.FEATURES.get('ENABLE_CREATOR_GROUP', False): - return get_organizations(user) - else: - return [] + organizations_set.update(get_organizations_for_non_course_creators(user)) + + # This allows people in the course creator group for an org to create + # libraries, which mimics course behavior. + if settings.FEATURES.get('ENABLE_CREATOR_GROUP', False): + organizations_set.update(get_organizations(user)) + + return sorted(organizations_set) def user_can_create_organizations(user): diff --git a/cms/djangoapps/contentstore/views/entrance_exam.py b/cms/djangoapps/contentstore/views/entrance_exam.py index bbefb0e9e876..5d914366bd9e 100644 --- a/cms/djangoapps/contentstore/views/entrance_exam.py +++ b/cms/djangoapps/contentstore/views/entrance_exam.py @@ -224,7 +224,7 @@ def _delete_entrance_exam(request, course_key): if course.entrance_exam_id: metadata = { 'entrance_exam_enabled': False, - 'entrance_exam_minimum_score_pct': None, + 'entrance_exam_minimum_score_pct': _get_default_entrance_exam_minimum_pct(), 'entrance_exam_id': None, } CourseMetadata.update_from_dict(metadata, course, request.user) diff --git a/cms/djangoapps/contentstore/views/tests/test_assets.py b/cms/djangoapps/contentstore/views/tests/test_assets.py index 2b13338c0dc7..e7dbcfe9f55a 100644 --- a/cms/djangoapps/contentstore/views/tests/test_assets.py +++ b/cms/djangoapps/contentstore/views/tests/test_assets.py @@ -12,13 +12,11 @@ from ddt import data, ddt from django.conf import settings from django.test.utils import override_settings -from edx_toggles.toggles.testutils import override_waffle_flag from opaque_keys.edx.keys import AssetKey from opaque_keys.edx.locator import CourseLocator from PIL import Image from pytz import UTC -from cms.djangoapps.contentstore import toggles from cms.djangoapps.contentstore.tests.utils import CourseTestCase from cms.djangoapps.contentstore.utils import reverse_course_url from cms.djangoapps.contentstore.views import assets @@ -87,10 +85,9 @@ class BasicAssetsTestCase(AssetsTestCase): Test getting assets via html w/o additional args """ - @override_waffle_flag(toggles.LEGACY_STUDIO_FILES_UPLOADS, True) def test_basic(self): resp = self.client.get(self.url, HTTP_ACCEPT='text/html') - self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.status_code, 302) def test_static_url_generation(self): diff --git a/cms/djangoapps/contentstore/views/tests/test_block.py b/cms/djangoapps/contentstore/views/tests/test_block.py index c94038a508b5..b17c71668a78 100644 --- a/cms/djangoapps/contentstore/views/tests/test_block.py +++ b/cms/djangoapps/contentstore/views/tests/test_block.py @@ -87,6 +87,7 @@ add_container_page_publishing_info, create_xblock_info, ) +from common.test.utils import assert_dict_contains_subset class AsideTest(XBlockAside): @@ -864,7 +865,8 @@ def test_duplicate_event(self): XBLOCK_DUPLICATED.connect(event_receiver) usage_key = self._duplicate_and_verify(self.vert_usage_key, self.seq_usage_key) event_receiver.assert_called() - self.assertDictContainsSubset( + assert_dict_contains_subset( + self, { "signal": XBLOCK_DUPLICATED, "sender": None, @@ -1854,7 +1856,7 @@ def setUp(self): @XBlockAside.register_temp_plugin(AsideTest, "test_aside") @patch( - "xmodule.modulestore.split_mongo.caching_descriptor_system.CachingDescriptorSystem.applicable_aside_types", + "xmodule.modulestore.split_mongo.runtime.SplitModuleStoreRuntime.applicable_aside_types", lambda self, block: ["test_aside"], ) def test_duplicate_equality_with_asides(self): @@ -2699,8 +2701,8 @@ def test_add_groups(self): group_id_to_child = split_test.group_id_to_child.copy() self.assertEqual(2, len(group_id_to_child)) - # CachingDescriptorSystem is used in tests. - # CachingDescriptorSystem doesn't have user service, that's needed for + # SplitModuleStoreRuntime is used in tests. + # SplitModuleStoreRuntime doesn't have user service, that's needed for # SplitTestBlock. So, in this line of code we add this service manually. split_test.runtime._services["user"] = DjangoXBlockUserService( # pylint: disable=protected-access self.user @@ -4455,7 +4457,7 @@ def test_self_paced_item_visibility_state(self): @patch( - "xmodule.modulestore.split_mongo.caching_descriptor_system.CachingDescriptorSystem.applicable_aside_types", + "xmodule.modulestore.split_mongo.runtime.SplitModuleStoreRuntime.applicable_aside_types", lambda self, block: ["test_aside"], ) class TestUpdateFromSource(ModuleStoreTestCase): diff --git a/cms/djangoapps/contentstore/views/tests/test_clipboard_paste.py b/cms/djangoapps/contentstore/views/tests/test_clipboard_paste.py index 3fae9d996fd2..2ca03ccf892b 100644 --- a/cms/djangoapps/contentstore/views/tests/test_clipboard_paste.py +++ b/cms/djangoapps/contentstore/views/tests/test_clipboard_paste.py @@ -16,7 +16,7 @@ from openedx_tagging.core.tagging.models import Tag from organizations.models import Organization from xmodule.modulestore.django import contentstore, modulestore -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, upload_file_to_course +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, upload_file_to_course, ImmediateOnCommitMixin from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory, ToyCourseFactory, LibraryFactory from cms.djangoapps.contentstore.utils import reverse_usage_url @@ -400,7 +400,7 @@ def test_paste_with_assets(self): assert source_pic2_hash != dest_pic2_hash # Because there was a conflict, this file was unchanged. -class ClipboardPasteFromV2LibraryTestCase(OpenEdxEventsTestMixin, ModuleStoreTestCase): +class ClipboardPasteFromV2LibraryTestCase(OpenEdxEventsTestMixin, ImmediateOnCommitMixin, ModuleStoreTestCase): """ Test Clipboard Paste functionality with a "new" (as of Sumac) library """ diff --git a/cms/djangoapps/contentstore/views/tests/test_course_index.py b/cms/djangoapps/contentstore/views/tests/test_course_index.py index a22ce637fedd..58c425c601a3 100644 --- a/cms/djangoapps/contentstore/views/tests/test_course_index.py +++ b/cms/djangoapps/contentstore/views/tests/test_course_index.py @@ -8,496 +8,28 @@ from unittest import mock, skip import ddt -import lxml import pytz -from django.conf import settings from django.core.exceptions import PermissionDenied -from django.test.utils import override_settings from django.utils.translation import gettext as _ -from edx_toggles.toggles.testutils import override_waffle_flag -from opaque_keys.edx.locator import CourseLocator from search.api import perform_search -from cms.djangoapps.contentstore import toggles from cms.djangoapps.contentstore.courseware_index import CoursewareSearchIndexer, SearchIndexingError from cms.djangoapps.contentstore.tests.utils import CourseTestCase from cms.djangoapps.contentstore.utils import ( - add_instructor, - get_proctored_exam_settings_url, reverse_course_url, reverse_usage_url ) -from common.djangoapps.course_action_state.managers import CourseRerunUIStateManager -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 CourseStaffRole, GlobalStaff, LibraryUserRole from common.djangoapps.student.tests.factories import UserFactory -from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory from openedx.core.djangoapps.waffle_utils.testutils import WAFFLE_TABLES 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 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 xmodule.modulestore.tests.factories import BlockFactory, 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_storage_handlers.view_handlers import VisibilityState, create_xblock_info -@override_waffle_flag(toggles.LEGACY_STUDIO_HOME, True) -@override_waffle_flag(toggles.LEGACY_STUDIO_COURSE_OUTLINE, True) -class TestCourseIndex(CourseTestCase): - """ - Unit tests for getting the list of courses and the course outline. - """ - - MODULESTORE = TEST_DATA_SPLIT_MODULESTORE - - def setUp(self): - """ - Add a course with odd characters in the fields - """ - super().setUp() - # had a problem where index showed course but has_access failed to retrieve it for non-staff - self.odd_course = CourseFactory.create( - org='test.org_1-2', - number='test-2.3_course', - display_name='dotted.course.name-2', - ) - CourseOverviewFactory.create( - id=self.odd_course.id, - org=self.odd_course.org, - display_name=self.odd_course.display_name, - ) - - def check_courses_on_index(self, authed_client, expected_course_tab_len): - """ - Test that the React course listing is present. - """ - index_url = '/home/' - index_response = authed_client.get(index_url, {}, HTTP_ACCEPT='text/html') - parsed_html = lxml.html.fromstring(index_response.content) - courses_tab = parsed_html.find_class('react-course-listing') - self.assertEqual(len(courses_tab), expected_course_tab_len) - - def test_libraries_on_index(self): - """ - Test that the library tab is present. - """ - def _assert_library_tab_present(response): - """ - Asserts there's a library tab. - """ - parsed_html = lxml.html.fromstring(response.content) - library_tab = parsed_html.find_class('react-library-listing') - self.assertEqual(len(library_tab), 1) - - # Add a library: - lib1 = LibraryFactory.create() # lint-amnesty, pylint: disable=unused-variable - - index_url = '/home/' - index_response = self.client.get(index_url, {}, HTTP_ACCEPT='text/html') - _assert_library_tab_present(index_response) - - # Make sure libraries are visible to non-staff users too - self.client.logout() - non_staff_user, non_staff_userpassword = self.create_non_staff_user() - lib2 = LibraryFactory.create(user_id=non_staff_user.id) - LibraryUserRole(lib2.location.library_key).add_users(non_staff_user) - self.client.login(username=non_staff_user.username, password=non_staff_userpassword) - index_response = self.client.get(index_url, {}, HTTP_ACCEPT='text/html') - _assert_library_tab_present(index_response) - - def test_is_staff_access(self): - """ - Test that people with is_staff see the courses and can navigate into them - """ - self.check_courses_on_index(self.client, 1) - - def test_negative_conditions(self): - """ - Test the error conditions for the access - """ - outline_url = reverse_course_url('course_handler', self.course.id) - # register a non-staff member and try to delete the course branch - non_staff_client, _ = self.create_non_staff_authed_user_client() - response = non_staff_client.delete(outline_url, {}, HTTP_ACCEPT='application/json') - if self.course.id.deprecated: - self.assertEqual(response.status_code, 404) - else: - self.assertEqual(response.status_code, 403) - - def test_course_staff_access(self): - """ - Make and register course_staff and ensure they can access the courses - """ - course_staff_client, course_staff = self.create_non_staff_authed_user_client() - for course in [self.course, self.odd_course]: - permission_url = reverse_course_url('course_team_handler', course.id, kwargs={'email': course_staff.email}) - - self.client.post( - permission_url, - data=json.dumps({"role": "staff"}), - content_type="application/json", - HTTP_ACCEPT="application/json", - ) - - # test access - self.check_courses_on_index(course_staff_client, 1) - - def test_json_responses(self): - - outline_url = reverse_course_url('course_handler', self.course.id) - chapter = BlockFactory.create(parent_location=self.course.location, category='chapter', display_name="Week 1") - lesson = BlockFactory.create(parent_location=chapter.location, category='sequential', display_name="Lesson 1") - subsection = BlockFactory.create( - parent_location=lesson.location, - category='vertical', - display_name='Subsection 1' - ) - BlockFactory.create(parent_location=subsection.location, category="video", display_name="My Video") - - resp = self.client.get(outline_url, HTTP_ACCEPT='application/json') - - if self.course.id.deprecated: - self.assertEqual(resp.status_code, 404) - return - - json_response = json.loads(resp.content.decode('utf-8')) - - # First spot check some values in the root response - self.assertEqual(json_response['category'], 'course') - self.assertEqual(json_response['id'], str(self.course.location)) - self.assertEqual(json_response['display_name'], self.course.display_name) - self.assertTrue(json_response['published']) - self.assertIsNone(json_response['visibility_state']) - - # Now verify the first child - children = json_response['child_info']['children'] - self.assertGreater(len(children), 0) - first_child_response = children[0] - self.assertEqual(first_child_response['category'], 'chapter') - self.assertEqual(first_child_response['id'], str(chapter.location)) - self.assertEqual(first_child_response['display_name'], 'Week 1') - self.assertTrue(json_response['published']) - self.assertEqual(first_child_response['visibility_state'], VisibilityState.unscheduled) - self.assertGreater(len(first_child_response['child_info']['children']), 0) - - # Finally, validate the entire response for consistency - self.assert_correct_json_response(json_response) - - def test_notifications_handler_get(self): - state = CourseRerunUIStateManager.State.FAILED - action = CourseRerunUIStateManager.ACTION - should_display = True - - # try when no notification exists - notification_url = reverse_course_url('course_notifications_handler', self.course.id, kwargs={ - 'action_state_id': 1, - }) - - resp = self.client.get(notification_url, HTTP_ACCEPT='application/json') - - # verify that we get an empty dict out - self.assertEqual(resp.status_code, 400) - - # create a test notification - rerun_state = CourseRerunState.objects.update_state( - course_key=self.course.id, - new_state=state, - allow_not_found=True - ) - CourseRerunState.objects.update_should_display( - entry_id=rerun_state.id, - user=UserFactory(), - should_display=should_display - ) - - # try to get information on this notification - notification_url = reverse_course_url('course_notifications_handler', self.course.id, kwargs={ - 'action_state_id': rerun_state.id, - }) - resp = self.client.get(notification_url, HTTP_ACCEPT='application/json') - - json_response = json.loads(resp.content.decode('utf-8')) - - self.assertEqual(json_response['state'], state) - self.assertEqual(json_response['action'], action) - self.assertEqual(json_response['should_display'], should_display) - - def test_notifications_handler_dismiss(self): - state = CourseRerunUIStateManager.State.FAILED - should_display = True - rerun_course_key = CourseLocator(org='testx', course='test_course', run='test_run') - - # add an instructor to this course - user2 = UserFactory() - add_instructor(rerun_course_key, self.user, user2) - - # create a test notification - rerun_state = CourseRerunState.objects.update_state( - course_key=rerun_course_key, - new_state=state, - allow_not_found=True - ) - CourseRerunState.objects.update_should_display( - entry_id=rerun_state.id, - user=user2, - should_display=should_display - ) - - # try to get information on this notification - notification_dismiss_url = reverse_course_url('course_notifications_handler', self.course.id, kwargs={ - 'action_state_id': rerun_state.id, - }) - resp = self.client.delete(notification_dismiss_url) - self.assertEqual(resp.status_code, 200) - - with self.assertRaises(CourseRerunState.DoesNotExist): - # delete nofications that are dismissed - CourseRerunState.objects.get(id=rerun_state.id) - - self.assertFalse(has_course_author_access(user2, rerun_course_key)) - - def assert_correct_json_response(self, json_response): - """ - Asserts that the JSON response is syntactically consistent - """ - self.assertIsNotNone(json_response['display_name']) - self.assertIsNotNone(json_response['id']) - self.assertIsNotNone(json_response['category']) - self.assertTrue(json_response['published']) - if json_response.get('child_info', None): - for child_response in json_response['child_info']['children']: - self.assert_correct_json_response(child_response) - - def test_course_updates_invalid_url(self): - """ - Tests the error conditions for the invalid course updates URL. - """ - # Testing the response code by passing slash separated course id whose format is valid but no course - # having this id exists. - invalid_course_key = f'{self.course.id}_blah_blah_blah' - course_updates_url = reverse_course_url('course_info_handler', invalid_course_key) - response = self.client.get(course_updates_url) - self.assertEqual(response.status_code, 404) - - # Testing the response code by passing split course id whose format is valid but no course - # having this id exists. - split_course_key = CourseLocator(org='orgASD', course='course_01213', run='Run_0_hhh_hhh_hhh') - course_updates_url_split = reverse_course_url('course_info_handler', split_course_key) - response = self.client.get(course_updates_url_split) - self.assertEqual(response.status_code, 404) - - # Testing the response by passing split course id whose format is invalid. - invalid_course_id = f'invalid.course.key/{split_course_key}' - course_updates_url_split = reverse_course_url('course_info_handler', invalid_course_id) - response = self.client.get(course_updates_url_split) - self.assertEqual(response.status_code, 404) - - def test_course_index_invalid_url(self): - """ - Tests the error conditions for the invalid course index URL. - """ - # Testing the response code by passing slash separated course key, no course - # having this key exists. - invalid_course_key = f'{self.course.id}_some_invalid_run' - course_outline_url = reverse_course_url('course_handler', invalid_course_key) - response = self.client.get_html(course_outline_url) - self.assertEqual(response.status_code, 404) - - # Testing the response code by passing split course key, no course - # having this key exists. - split_course_key = CourseLocator(org='invalid_org', course='course_01111', run='Run_0_invalid') - course_outline_url_split = reverse_course_url('course_handler', split_course_key) - response = self.client.get_html(course_outline_url_split) - self.assertEqual(response.status_code, 404) - - def test_course_outline_with_display_course_number_as_none(self): - """ - Tests course outline when 'display_coursenumber' field is none. - """ - # Change 'display_coursenumber' field to None and update the course. - self.course.display_coursenumber = None - updated_course = self.update_course(self.course, self.user.id) - - # Assert that 'display_coursenumber' field has been changed successfully. - self.assertEqual(updated_course.display_coursenumber, None) - - # Perform GET request on course outline url with the course id. - course_outline_url = reverse_course_url('course_handler', updated_course.id) - response = self.client.get_html(course_outline_url) - - # course_handler raise 404 for old mongo course - if self.course.id.deprecated: - self.assertEqual(response.status_code, 404) - return - - # Assert that response code is 200. - self.assertEqual(response.status_code, 200) - - # Assert that 'display_course_number' is being set to "" (as display_coursenumber was None). - self.assertContains(response, 'display_course_number: ""') - - -@override_waffle_flag(toggles.LEGACY_STUDIO_HOME, True) -@ddt.ddt -class TestCourseIndexArchived(CourseTestCase): - """ - Unit tests for testing the course index list when there are archived courses. - """ - - MODULESTORE = TEST_DATA_SPLIT_MODULESTORE - - NOW = datetime.datetime.now(pytz.utc) - DAY = datetime.timedelta(days=1) - YESTERDAY = NOW - DAY - TOMORROW = NOW + DAY - - ORG = 'MyOrg' - - ENABLE_SEPARATE_ARCHIVED_COURSES = settings.FEATURES.copy() - ENABLE_SEPARATE_ARCHIVED_COURSES['ENABLE_SEPARATE_ARCHIVED_COURSES'] = True - DISABLE_SEPARATE_ARCHIVED_COURSES = settings.FEATURES.copy() - DISABLE_SEPARATE_ARCHIVED_COURSES['ENABLE_SEPARATE_ARCHIVED_COURSES'] = False - - def setUp(self): - """ - Add courses with the end date set to various values - """ - super().setUp() - - # Base course has no end date (so is active) - self.course.end = None - self.course.display_name = 'Active Course 1' - self.ORG = self.course.location.org - self.save_course() - CourseOverviewFactory.create(id=self.course.id, org=self.ORG) - - # Active course has end date set to tomorrow - self.active_course = CourseFactory.create( - display_name='Active Course 2', - org=self.ORG, - end=self.TOMORROW, - ) - CourseOverviewFactory.create( - id=self.active_course.id, - org=self.ORG, - end=self.TOMORROW, - ) - - # Archived course has end date set to yesterday - self.archived_course = CourseFactory.create( - display_name='Archived Course', - org=self.ORG, - end=self.YESTERDAY, - ) - CourseOverviewFactory.create( - id=self.archived_course.id, - org=self.ORG, - end=self.YESTERDAY, - ) - - # Base user has global staff access - self.assertTrue(GlobalStaff().has_user(self.user)) - - # Staff user just has course staff access - self.staff, self.staff_password = self.create_non_staff_user() - for course in (self.course, self.active_course, self.archived_course): - CourseStaffRole(course.id).add_users(self.staff) - - def check_index_page_with_query_count(self, separate_archived_courses, org, mongo_queries, sql_queries): - """ - Checks the index page, and ensures the number of database queries is as expected. - """ - with self.assertNumQueries(sql_queries, table_ignorelist=WAFFLE_TABLES): - with check_mongo_calls(mongo_queries): - self.check_index_page(separate_archived_courses=separate_archived_courses, org=org) - - def check_index_page(self, separate_archived_courses, org): - """ - Ensure that the index page displays the archived courses as expected. - """ - index_url = '/home/' - index_params = {} - if org is not None: - index_params['org'] = org - index_response = self.client.get(index_url, index_params, HTTP_ACCEPT='text/html') - self.assertEqual(index_response.status_code, 200) - - parsed_html = lxml.html.fromstring(index_response.content) - course_tab = parsed_html.find_class('courses') - self.assertEqual(len(course_tab), 1) - archived_course_tab = parsed_html.find_class('archived-courses') - self.assertEqual(len(archived_course_tab), 1 if separate_archived_courses else 0) - - @ddt.data( - # Staff user has course staff access - (True, 'staff', None, 23), - (False, 'staff', None, 23), - # Base user has global staff access - (True, 'user', ORG, 23), - (False, 'user', ORG, 23), - (True, 'user', None, 23), - (False, 'user', None, 23), - ) - @ddt.unpack - def test_separate_archived_courses(self, separate_archived_courses, username, org, sql_queries): - """ - Ensure that archived courses are shown as expected for all user types, when the feature is enabled/disabled. - Also ensure that enabling the feature does not adversely affect the database query count. - """ - # Authenticate the requested user - user = getattr(self, username) - password = getattr(self, username + '_password') - self.client.login(username=user, password=password) - - # Enable/disable the feature before viewing the index page. - features = settings.FEATURES.copy() - features['ENABLE_SEPARATE_ARCHIVED_COURSES'] = separate_archived_courses - with override_settings(FEATURES=features): - self.check_index_page_with_query_count(separate_archived_courses=separate_archived_courses, - org=org, - mongo_queries=0, - sql_queries=sql_queries) - - @ddt.data( - # Staff user has course staff access - (True, 'staff', None, 23), - (False, 'staff', None, 23), - # Base user has global staff access - (True, 'user', ORG, 23), - (False, 'user', ORG, 23), - (True, 'user', None, 23), - (False, 'user', None, 23), - ) - @ddt.unpack - def test_separate_archived_courses_with_home_page_course_v2_api( - self, - separate_archived_courses, - username, - org, - sql_queries - ): - """ - Ensure that archived courses are shown as expected for all user types, when the feature is enabled/disabled. - Also ensure that enabling the feature does not adversely affect the database query count. - """ - # Authenticate the requested user - user = getattr(self, username) - password = getattr(self, username + '_password') - self.client.login(username=user, password=password) - - # Enable/disable the feature before viewing the index page. - features = settings.FEATURES.copy() - features['ENABLE_SEPARATE_ARCHIVED_COURSES'] = separate_archived_courses - with override_settings(FEATURES=features): - self.check_index_page_with_query_count(separate_archived_courses=separate_archived_courses, - org=org, - mongo_queries=0, - sql_queries=sql_queries) - - -@override_waffle_flag(toggles.LEGACY_STUDIO_COURSE_OUTLINE, True) @ddt.ddt class TestCourseOutline(CourseTestCase): """ @@ -689,38 +221,15 @@ def test_verify_warn_only_on_enabled_blocks(self, enabled_block_types, deprecate expected_block_types ) - @override_settings(FEATURES={'ENABLE_EXAM_SETTINGS_HTML_VIEW': True}) - @mock.patch('cms.djangoapps.models.settings.course_metadata.CourseMetadata.validate_proctoring_settings') - def test_proctoring_link_is_visible(self, mock_validate_proctoring_settings): - """ - Test to check proctored exam settings mfe url is rendering properly - """ - mock_validate_proctoring_settings.return_value = [ - { - 'key': 'proctoring_provider', - 'message': 'error message', - 'model': {'display_name': 'proctoring_provider'} - }, - { - 'key': 'proctoring_provider', - 'message': 'error message', - 'model': {'display_name': 'proctoring_provider'} - } - ] - response = self.client.get_html(reverse_course_url('course_handler', self.course.id)) - proctored_exam_settings_url = get_proctored_exam_settings_url(self.course.id) - self.assertContains(response, proctored_exam_settings_url, 2) - def test_number_of_calls_to_db(self): """ Test to check number of queries made to mysql and mongo """ - with self.assertNumQueries(39, table_ignorelist=WAFFLE_TABLES): + with self.assertNumQueries(21, table_ignorelist=WAFFLE_TABLES): with check_mongo_calls(3): - self.client.get_html(reverse_course_url('course_handler', self.course.id)) + self.client.get(reverse_course_url('course_handler', self.course.id), content_type="application/json") -@override_waffle_flag(toggles.LEGACY_STUDIO_COURSE_OUTLINE, True) class TestCourseReIndex(CourseTestCase): """ Unit tests for the course outline. diff --git a/cms/djangoapps/contentstore/views/tests/test_exam_settings_view.py b/cms/djangoapps/contentstore/views/tests/test_exam_settings_view.py index 9bad2c77fc1a..88c4aa27eb06 100644 --- a/cms/djangoapps/contentstore/views/tests/test_exam_settings_view.py +++ b/cms/djangoapps/contentstore/views/tests/test_exam_settings_view.py @@ -24,7 +24,6 @@ "ENABLE_PROCTORED_EXAMS": True, }, ) -@override_waffle_flag(toggles.LEGACY_STUDIO_COURSE_OUTLINE, True) @override_waffle_flag(toggles.LEGACY_STUDIO_CERTIFICATES, True) @override_waffle_flag(toggles.LEGACY_STUDIO_SCHEDULE_DETAILS, True) @override_waffle_flag(toggles.LEGACY_STUDIO_CONFIGURATIONS, True) @@ -93,7 +92,6 @@ def test_view_with_exam_settings_enabled(self, handler): ) @ddt.data( "advanced_settings_handler", - "course_handler", ) def test_exam_settings_alert_with_exam_settings_enabled(self, page_handler): """ @@ -130,7 +128,6 @@ def test_exam_settings_alert_with_exam_settings_enabled(self, page_handler): ) @ddt.data( "advanced_settings_handler", - "course_handler", ) @override_waffle_flag(toggles.LEGACY_STUDIO_EXAM_SETTINGS, True) def test_exam_settings_alert_with_exam_settings_disabled(self, page_handler): @@ -173,7 +170,6 @@ def test_exam_settings_alert_with_exam_settings_disabled(self, page_handler): ) @ddt.data( "advanced_settings_handler", - "course_handler", ) def test_invalid_provider_alert(self, page_handler): """ @@ -198,7 +194,6 @@ def test_invalid_provider_alert(self, page_handler): @ddt.data( "advanced_settings_handler", - "course_handler", ) def test_exam_settings_alert_not_shown(self, page_handler): """ diff --git a/cms/djangoapps/contentstore/views/tests/test_header_menu.py b/cms/djangoapps/contentstore/views/tests/test_header_menu.py deleted file mode 100644 index fb961cc4fa89..000000000000 --- a/cms/djangoapps/contentstore/views/tests/test_header_menu.py +++ /dev/null @@ -1,93 +0,0 @@ -""" -Course Header Menu Tests. -""" -from unittest import SkipTest - -from django.conf import settings -from django.test.utils import override_settings -from edx_toggles.toggles.testutils import override_waffle_flag - -from cms.djangoapps.contentstore import toggles -from cms.djangoapps.contentstore.tests.utils import CourseTestCase -from cms.djangoapps.contentstore.utils import reverse_course_url -from common.djangoapps.util.testing import UrlResetMixin - -FEATURES_WITH_CERTS_ENABLED = settings.FEATURES.copy() -FEATURES_WITH_CERTS_ENABLED['CERTIFICATES_HTML_VIEW'] = True - -FEATURES_WITH_EXAM_SETTINGS_ENABLED = settings.FEATURES.copy() -FEATURES_WITH_EXAM_SETTINGS_ENABLED['ENABLE_EXAM_SETTINGS_HTML_VIEW'] = True - -FEATURES_WITH_EXAM_SETTINGS_DISABLED = settings.FEATURES.copy() -FEATURES_WITH_EXAM_SETTINGS_DISABLED['ENABLE_EXAM_SETTINGS_HTML_VIEW'] = False - - -@override_settings(FEATURES=FEATURES_WITH_CERTS_ENABLED) -@override_waffle_flag(toggles.LEGACY_STUDIO_COURSE_OUTLINE, True) -class TestHeaderMenu(CourseTestCase, UrlResetMixin): - """ - Unit tests for the course header menu. - """ - def setUp(self): - """ - Set up the for the course header menu tests. - """ - super().setUp() - self.reset_urls() - - def test_header_menu_without_web_certs_enabled(self): - """ - Tests course header menu should not have `Certificates` menu item - if course has not web/HTML certificates enabled. - """ - # course_handler raise 404 for old mongo course - if self.course.id.deprecated: - raise SkipTest("course_handler raise 404 for old mongo course") - self.course.cert_html_view_enabled = False - self.save_course() - outline_url = reverse_course_url('course_handler', self.course.id) - resp = self.client.get(outline_url, HTTP_ACCEPT='text/html') - self.assertEqual(resp.status_code, 200) - self.assertNotContains(resp, '
  • ', + '', + '
  • ' + ].join('') + ), + { + speed: speed + } + ).toString(); + }); + + HtmlUtils.setHtml( + speedsContainer, + HtmlUtils.HTML(speedsList) + ); + this.speedLinks = new Iterator(speedsContainer.find('.speed-option')); + HtmlUtils.prepend( + this.state.el.find('.secondary-controls'), + HtmlUtils.HTML(this.el) + ); + this.setActiveSpeed(currentSpeed); + + // set dynamic id for instruction element to avoid collisions + this.el.find('.instructions').attr('id', instructionsId); + this.speedButton.attr('aria-describedby', instructionsId); + }, + + /** + * Bind any necessary function callbacks to DOM events (click, + * mousemove, etc.). + */ + bindHandlers: function () { + // Attach various events handlers to the speed menu button. + this.el.on({ + mouseenter: this.mouseEnterHandler, + mouseleave: this.mouseLeaveHandler, + click: this.openMenu, + keydown: this.keyDownMenuHandler + }); + + // Attach click and keydown event handlers to the individual speed + // entries. + this.speedsContainer.on({ + click: this.clickLinkHandler, + keydown: this.keyDownLinkHandler + }, '.speed-option'); + + this.state.el.on({ + 'speed:set': this.onSetSpeed, + 'speed:render': this.onRenderSpeed + }); + this.state.el.on('destroy', this.destroy); + }, + + onSetSpeed: function (event, speed) { + this.setSpeed(speed, true); + }, + + onRenderSpeed: function (event, speeds, currentSpeed) { + this.render(speeds, currentSpeed); + }, + + /** + * Check if playbackRate supports by browser. If browser supports, 1.0 + * should be returned by playbackRate property. In this case, function + * return True. Otherwise, False will be returned. + * iOS doesn't support speed change. + * @param {object} state The object containing the state of the video + * player. + * @return {boolean} + * true: Browser support playbackRate functionality. + * false: Browser doesn't support playbackRate functionality. + */ + isPlaybackRatesSupported: function (state) { + let isHtml5 = state.videoType === 'html5', + isTouch = state.isTouch, + video = document.createElement('video'); + + // eslint-disable-next-line no-extra-boolean-cast + return !isTouch || (isHtml5 && !Boolean(video.playbackRate)); + }, + + /** + * Opens speed menu. + * @param {boolean} [bindEvent] Click event will be attached on window. + */ + openMenu: function (bindEvent) { + // When speed entries have focus, the menu stays open on + // mouseleave. A clickHandler is added to the window + // element to have clicks close the menu when they happen + // outside of it. + if (bindEvent) { + $(window).on('click.speedMenu', this.clickMenuHandler); + } + + this.el.addClass('is-opened'); + this.speedButton + .attr('tabindex', -1) + .attr('aria-expanded', 'true'); + }, + + /** + * Closes speed menu. + * @param {boolean} [unBindEvent] Click event will be detached from window. + */ + closeMenu: function (unBindEvent) { + // Remove the previously added clickHandler from window element. + if (unBindEvent) { + $(window).off('click.speedMenu'); + } + + this.el.removeClass('is-opened'); + this.speedButton + .attr('tabindex', 0) + .attr('aria-expanded', 'false'); + }, + + /** + * Sets new current speed for the speed control and triggers `speedchange` + * event if needed. + * @param {string|number} speed Speed to be set. + * @param {boolean} [silent] Sets the new speed without triggering + * `speedchange` event. + * @param {boolean} [forceUpdate] Updates the speed even if it's + * not differs from current speed. + */ + setSpeed: function (speed, silent, forceUpdate) { + let newSpeed = this.state.speedToString(speed); + if (newSpeed !== this.currentSpeed || forceUpdate) { + this.speedsContainer + .find('li') + .siblings("li[data-speed='" + newSpeed + "']"); + + this.speedButton.find('.value').text(newSpeed + 'x'); + this.currentSpeed = newSpeed; + + if (!silent) { + this.el.trigger('speedchange', [newSpeed, this.state.speed]); + } + } + + this.resetActiveSpeed(); + this.setActiveSpeed(newSpeed); + }, + + resetActiveSpeed: function () { + let speedOptions = this.speedsContainer.find('li'); + + $(speedOptions).each(function (index, el) { + $(el).removeClass('is-active') + .find('.speed-option') + .attr('aria-pressed', 'false'); + }); + }, + + setActiveSpeed: function (speed) { + let speedOption = this.speedsContainer.find('li[data-speed="' + this.state.speedToString(speed) + '"]'); + + speedOption.addClass('is-active') + .find('.speed-option') + .attr('aria-pressed', 'true'); + + this.speedButton.attr('title', gettext('Video speed: ') + this.state.speedToString(speed) + 'x'); + }, + + /** + * Click event handler for the menu. + * @param {jquery Event} event + */ + clickMenuHandler: function () { + this.closeMenu(); + + return false; + }, + + /** + * Click event handler for speed links. + * @param {jquery Event} event + */ + clickLinkHandler: function (event) { + let el = $(event.currentTarget).parent(), + speed = $(el).data('speed'); + + this.resetActiveSpeed(); + this.setActiveSpeed(speed); + this.state.videoCommands.execute('speed', speed); + this.closeMenu(true); + + return false; + }, + + /** + * Mouseenter event handler for the menu. + * @param {jquery Event} event + */ + mouseEnterHandler: function () { + this.openMenu(); + + return false; + }, + + /** + * Mouseleave event handler for the menu. + * @param {jquery Event} event + */ + mouseLeaveHandler: function () { + // Only close the menu is no speed entry has focus. + if (!this.speedLinks.list.is(':focus')) { + this.closeMenu(); + } + + return false; + }, + + /** + * Keydown event handler for the menu. + * @param {jquery Event} event + */ + keyDownMenuHandler: function (event) { + let KEY = $.ui.keyCode, + keyCode = event.keyCode; + + // eslint-disable-next-line default-case + switch (keyCode) { + // Open menu and focus on last element of list above it. + case KEY.ENTER: + case KEY.SPACE: + case KEY.UP: + this.openMenu(true); + this.speedLinks.last().focus(); + break; + // Close menu. + case KEY.ESCAPE: + this.closeMenu(true); + break; + } + // We do not stop propagation and default behavior on a TAB + // keypress. + return event.keyCode === KEY.TAB; + }, + + /** + * Keydown event handler for speed links. + * @param {jquery Event} event + */ + keyDownLinkHandler: function (event) { + // ALT key is used to change (alternate) the function of + // other pressed keys. In this, do nothing. + if (event.altKey) { + return true; + } + + let KEY = $.ui.keyCode, + self = this, + parent = $(event.currentTarget).parent(), + index = parent.index(), + speed = parent.data('speed'); + + // eslint-disable-next-line default-case + switch (event.keyCode) { + // Close menu. + case KEY.TAB: + // Closes menu after 25ms delay to change `tabindex` after + // finishing default behavior. + setTimeout(function () { + self.closeMenu(true); + }, 25); + + return true; + // Close menu and give focus to speed control. + case KEY.ESCAPE: + this.closeMenu(true); + this.speedButton.focus(); + + return false; + // Scroll up menu, wrapping at the top. Keep menu open. + case KEY.UP: + // Shift + Arrows keyboard shortcut might be used by + // screen readers. In this, do nothing. + if (event.shiftKey) { + return true; + } + + this.speedLinks.prev(index).focus(); + return false; + // Scroll down menu, wrapping at the bottom. Keep menu + // open. + case KEY.DOWN: + // Shift + Arrows keyboard shortcut might be used by + // screen readers. In this, do nothing. + if (event.shiftKey) { + return true; + } + + this.speedLinks.next(index).focus(); + return false; + // Close menu, give focus to speed control and change + // speed. + case KEY.ENTER: + case KEY.SPACE: + this.closeMenu(true); + this.speedButton.focus(); + this.setSpeed(this.state.speedToString(speed)); + + return false; + } + + return true; + } +}; + +export default VideoSpeedControl; diff --git a/xmodule/assets/video/public/js/095_video_context_menu.js b/xmodule/assets/video/public/js/095_video_context_menu.js new file mode 100644 index 000000000000..42544738fcab --- /dev/null +++ b/xmodule/assets/video/public/js/095_video_context_menu.js @@ -0,0 +1,698 @@ +import _ from 'underscore'; +import Component from './00_component.js'; + +let AbstractItem = Component.extend({ + initialize: function (options) { + this.options = $.extend(true, { + label: '', + prefix: 'edx-', + dataAttrs: {menu: this}, + attrs: {}, + items: [], + callback: $.noop, + initialize: $.noop + }, options); + + this.id = _.uniqueId(); + this.element = this.createElement(); + this.element.attr(this.options.attrs).data(this.options.dataAttrs); + this.children = []; + this.delegateEvents(); + this.options.initialize.call(this, this); + }, + destroy: function () { + _.invoke(this.getChildren(), 'destroy'); + this.undelegateEvents(); + this.getElement().remove(); + }, + open: function () { + this.getElement().addClass('is-opened'); + return this; + }, + close: function () { + }, + closeSiblings: function () { + _.invoke(this.getSiblings(), 'close'); + return this; + }, + getElement: function () { + return this.element; + }, + addChild: function (child) { + let firstChild = null, + lastChild = null; + if (this.hasChildren()) { + lastChild = this.getLastChild(); + lastChild.next = child; + firstChild = this.getFirstChild(); + firstChild.prev = child; + } + child.parent = this; + child.next = firstChild; + child.prev = lastChild; + this.children.push(child); + return this; + }, + getChildren: function () { + // Returns the copy. + return this.children.concat(); + }, + hasChildren: function () { + return this.getChildren().length > 0; + }, + getFirstChild: function () { + return _.first(this.children); + }, + getLastChild: function () { + return _.last(this.children); + }, + bindEvent: function (element, events, handler) { + $(element).on(this.addNamespace(events), handler); + return this; + }, + getNext: function () { + let item = this.next; + while (item.isHidden() && this.id !== item.id) { + item = item.next; + } + return item; + }, + getPrev: function () { + let item = this.prev; + while (item.isHidden() && this.id !== item.id) { + item = item.prev; + } + return item; + }, + createElement: function () { + return null; + }, + getRoot: function () { + let item = this; + while (item.parent) { + item = item.parent; + } + return item; + }, + populateElement: function () { + }, + focus: function () { + this.getElement().focus(); + this.closeSiblings(); + return this; + }, + isHidden: function () { + return this.getElement().is(':hidden'); + }, + getSiblings: function () { + let items = [], + item = this; + while (item.next && item.next.id !== this.id) { + item = item.next; + items.push(item); + } + return items; + }, + select: function () { + }, + unselect: function () { + }, + setLabel: function () { + }, + itemHandler: function () { + }, + keyDownHandler: function () { + }, + delegateEvents: function () { + }, + undelegateEvents: function () { + this.getElement().off('.' + this.id); + }, + addNamespace: function (events) { + return _.map(events.split(/\s+/), function (event) { + return event + '.' + this.id; + }, this).join(' '); + } +}); + +let AbstractMenu = AbstractItem.extend({ + delegateEvents: function () { + this.bindEvent(this.getElement(), 'keydown mouseleave mouseover', this.itemHandler.bind(this)) + .bindEvent(this.getElement(), 'contextmenu', function (event) { + event.preventDefault(); + }); + return this; + }, + + populateElement: function () { + let fragment = document.createDocumentFragment(); + + _.each(this.getChildren(), function (child) { + fragment.appendChild(child.populateElement()[0]); + }, this); + + this.appendContent([fragment]); + this.isRendered = true; + return this.getElement(); + }, + + close: function () { + this.closeChildren(); + this.getElement().removeClass('is-opened'); + return this; + }, + + closeChildren: function () { + _.invoke(this.getChildren(), 'close'); + return this; + }, + + itemHandler: function (event) { + event.preventDefault(); + let item = $(event.target).data('menu'); + // eslint-disable-next-line default-case + switch (event.type) { + case 'keydown': + this.keyDownHandler.call(this, event, item); + break; + case 'mouseover': + this.mouseOverHandler.call(this, event, item); + break; + case 'mouseleave': + this.mouseLeaveHandler.call(this, event, item); + break; + } + }, + + keyDownHandler: function () { + }, + mouseOverHandler: function () { + }, + mouseLeaveHandler: function () { + } +}); + +let Menu = AbstractMenu.extend({ + initialize: function (options, contextmenuElement, container) { + this.contextmenuElement = $(contextmenuElement); + this.container = $(container); + this.overlay = this.getOverlay(); + AbstractMenu.prototype.initialize.apply(this, arguments); + this.build(this, this.options.items); + }, + + createElement: function () { + return $('
      ', { + class: ['contextmenu', this.options.prefix + 'contextmenu'].join(' '), + role: 'menu', + tabindex: -1 + }); + }, + + delegateEvents: function () { + AbstractMenu.prototype.delegateEvents.call(this); + this.bindEvent(this.contextmenuElement, 'contextmenu', this.contextmenuHandler.bind(this)) + .bindEvent(window, 'resize', _.debounce(this.close.bind(this), 100)); + return this; + }, + + destroy: function () { + AbstractMenu.prototype.destroy.call(this); + this.overlay.destroy(); + this.contextmenuElement.removeData('contextmenu'); + return this; + }, + + undelegateEvents: function () { + AbstractMenu.prototype.undelegateEvents.call(this); + this.contextmenuElement.off(this.addNamespace('contextmenu')); + this.overlay.undelegateEvents(); + return this; + }, + + appendContent: function (content) { + let $content = $(content); + this.getElement().append($content); + return this; + }, + + addChild: function () { + AbstractMenu.prototype.addChild.apply(this, arguments); + this.next = this.getFirstChild(); + this.prev = this.getLastChild(); + return this; + }, + + build: function (container, items) { + _.each(items, function (item) { + let child; + if (_.has(item, 'items')) { + child = this.build((new Submenu(item, this.contextmenuElement)), item.items); + } else { + child = new MenuItem(item); + } + container.addChild(child); + }, this); + return container; + }, + + focus: function () { + this.getElement().focus(); + return this; + }, + + open: function () { + let $menu = (this.isRendered) ? this.getElement() : this.populateElement(); + this.container.append($menu); + AbstractItem.prototype.open.call(this); + this.overlay.show(this.container); + return this; + }, + + close: function () { + AbstractMenu.prototype.close.call(this); + this.getElement().detach(); + this.overlay.hide(); + return this; + }, + + position: function (event) { + this.getElement().position({ + my: 'left top', + of: event, + collision: 'flipfit flipfit', + within: this.contextmenuElement + }); + + return this; + }, + + pointInContainerBox: function (x, y) { + let containerOffset = this.contextmenuElement.offset(), + containerBox = { + x0: containerOffset.left, + y0: containerOffset.top, + x1: containerOffset.left + this.contextmenuElement.outerWidth(), + y1: containerOffset.top + this.contextmenuElement.outerHeight() + }; + return containerBox.x0 <= x && x <= containerBox.x1 && containerBox.y0 <= y && y <= containerBox.y1; + }, + + getOverlay: function () { + return new Overlay( + this.close.bind(this), + function (event) { + event.preventDefault(); + if (this.pointInContainerBox(event.pageX, event.pageY)) { + this.position(event).focus(); + this.closeChildren(); + } else { + this.close(); + } + }.bind(this) + ); + }, + + contextmenuHandler: function (event) { + event.preventDefault(); + event.stopPropagation(); + this.open().position(event).focus(); + }, + + keyDownHandler: function (event, item) { + let KEY = $.ui.keyCode, + keyCode = event.keyCode; + + // eslint-disable-next-line default-case + switch (keyCode) { + case KEY.UP: + item.getPrev().focus(); + event.stopPropagation(); + break; + case KEY.DOWN: + item.getNext().focus(); + event.stopPropagation(); + break; + case KEY.TAB: + event.stopPropagation(); + break; + case KEY.ESCAPE: + this.close(); + break; + } + + return false; + } +}); + +let Overlay = Component.extend({ + ns: '.overlay', + initialize: function (clickHandler, contextmenuHandler) { + this.element = $('
      ', { + class: 'overlay' + }); + this.clickHandler = clickHandler; + this.contextmenuHandler = contextmenuHandler; + }, + + destroy: function () { + this.getElement().remove(); + this.undelegateEvents(); + }, + + getElement: function () { + return this.element; + }, + + hide: function () { + this.getElement().detach(); + this.undelegateEvents(); + return this; + }, + + show: function (container) { + let $elem = $(this.getElement()); + $(container).append($elem); + this.delegateEvents(); + return this; + }, + + delegateEvents: function () { + let self = this; + $(document) + .on('click' + this.ns, function () { + if (_.isFunction(self.clickHandler)) { + self.clickHandler.apply(this, arguments); + } + self.hide(); + }) + .on('contextmenu' + this.ns, function () { + if (_.isFunction(self.contextmenuHandler)) { + self.contextmenuHandler.apply(this, arguments); + } + }); + return this; + }, + + undelegateEvents: function () { + $(document).off(this.ns); + return this; + } +}); + +let Submenu = AbstractMenu.extend({ + initialize: function (options, contextmenuElement) { + this.contextmenuElement = contextmenuElement; + AbstractMenu.prototype.initialize.apply(this, arguments); + }, + + createElement: function () { + let $spanElem, + $listElem, + $element = $('
    1. ', { + class: ['submenu-item', 'menu-item', this.options.prefix + 'submenu-item'].join(' '), + 'aria-expanded': 'false', + 'aria-haspopup': 'true', + 'aria-labelledby': 'submenu-item-label-' + this.id, + role: 'menuitem', + tabindex: -1 + }); + + $spanElem = $('', { + id: 'submenu-item-label-' + this.id, + text: this.options.label + }); + this.label = $spanElem.appendTo($element); + + $listElem = $('
        ', { + class: ['submenu', this.options.prefix + 'submenu'].join(' '), + role: 'menu' + }); + + this.list = $listElem.appendTo($element); + + return $element; + }, + + appendContent: function (content) { + let $content = $(content); + this.list.append($content); + return this; + }, + + setLabel: function (label) { + this.label.text(label); + return this; + }, + + openKeyboard: function () { + if (this.hasChildren()) { + this.open(); + this.getFirstChild().focus(); + } + return this; + }, + + keyDownHandler: function (event) { + let KEY = $.ui.keyCode, + keyCode = event.keyCode; + + // eslint-disable-next-line default-case + switch (keyCode) { + case KEY.LEFT: + this.close().focus(); + event.stopPropagation(); + break; + case KEY.RIGHT: + case KEY.ENTER: + case KEY.SPACE: + this.openKeyboard(); + event.stopPropagation(); + break; + } + + return false; + }, + + open: function () { + AbstractMenu.prototype.open.call(this); + this.getElement().attr({'aria-expanded': 'true'}); + this.position(); + return this; + }, + + close: function () { + AbstractMenu.prototype.close.call(this); + this.getElement().attr({'aria-expanded': 'false'}); + return this; + }, + + position: function () { + this.list.position({ + my: 'left top', + at: 'right top', + of: this.getElement(), + collision: 'flipfit flipfit', + within: this.contextmenuElement + }); + return this; + }, + + mouseOverHandler: function () { + clearTimeout(this.timer); + this.timer = setTimeout(this.open.bind(this), 200); + this.focus(); + }, + + mouseLeaveHandler: function () { + clearTimeout(this.timer); + this.timer = setTimeout(this.close.bind(this), 200); + this.focus(); + } +}); + +let MenuItem = AbstractItem.extend({ + createElement: function () { + let classNames = [ + 'menu-item', this.options.prefix + 'menu-item', + this.options.isSelected ? 'is-selected' : '' + ].join(' '); + + return $('
      1. ', { + class: classNames, + 'aria-selected': this.options.isSelected ? 'true' : 'false', + role: 'menuitem', + tabindex: -1, + text: this.options.label + }); + }, + + populateElement: function () { + return this.getElement(); + }, + + delegateEvents: function () { + this.bindEvent(this.getElement(), 'click keydown contextmenu mouseover', this.itemHandler.bind(this)); + return this; + }, + + setLabel: function (label) { + this.getElement().text(label); + return this; + }, + + select: function (event) { + this.options.callback.call(this, event, this, this.options); + this.getElement() + .addClass('is-selected') + .attr({'aria-selected': 'true'}); + _.invoke(this.getSiblings(), 'unselect'); + // Hide the menu. + this.getRoot().close(); + return this; + }, + + unselect: function () { + this.getElement() + .removeClass('is-selected') + .attr({'aria-selected': 'false'}); + return this; + }, + + itemHandler: function (event) { + event.preventDefault(); + // eslint-disable-next-line default-case + switch (event.type) { + case 'contextmenu': + case 'click': + this.select(); + break; + case 'mouseover': + this.focus(); + event.stopPropagation(); + break; + case 'keydown': + this.keyDownHandler.call(this, event, this); + break; + } + }, + + keyDownHandler: function (event) { + let KEY = $.ui.keyCode, + keyCode = event.keyCode; + + // eslint-disable-next-line default-case + switch (keyCode) { + case KEY.RIGHT: + event.stopPropagation(); + break; + case KEY.ENTER: + case KEY.SPACE: + this.select(); + event.stopPropagation(); + break; + } + + return false; + } +}); + +let VideoContextMenu = function(state, i18n) { + let speedCallback = function(event, menuitem, options) { + let speed = parseFloat(options.label); + state.videoCommands.execute('speed', speed); + } + let options = { + items: [{ + label: i18n.Play, + callback: function () { + state.videoCommands.execute('togglePlayback'); + }, + initialize: function (menuitem) { + state.el.on({ + play: function () { + menuitem.setLabel(i18n.Pause); + }, + pause: function () { + menuitem.setLabel(i18n.Play); + } + }); + } + }, { + label: state.videoVolumeControl.getMuteStatus() ? i18n.Unmute : i18n.Mute, + callback: function () { + state.videoCommands.execute('toggleMute'); + }, + initialize: function (menuitem) { + state.el.on({ + volumechange: function () { + if (state.videoVolumeControl.getMuteStatus()) { + menuitem.setLabel(i18n.Unmute); + } else { + menuitem.setLabel(i18n.Mute); + } + } + }); + } + }, { + label: i18n['Fill browser'], + callback: function () { + state.videoCommands.execute('toggleFullScreen'); + }, + initialize: function (menuitem) { + state.el.on({ + fullscreen: function (event, isFullscreen) { + if (isFullscreen) { + menuitem.setLabel(i18n['Exit full browser']); + } else { + menuitem.setLabel(i18n['Fill browser']); + } + } + }); + } + }, { + label: i18n.Speed, + items: _.map(state.speeds, function (speed) { + let isSelected = parseFloat(speed) === state.speed; + return { + label: speed + 'x', callback: speedCallback, speed: speed, isSelected: isSelected + }; + }), + initialize: function (menuitem) { + state.el.on({ + speedchange: function (event, speed) { + // eslint-disable-next-line no-shadow + let item = menuitem.getChildren().filter(function (item) { + return item.options.speed === speed; + })[0]; + if (item) { + item.select(); + } + } + }); + } + } + ] + }; + + // eslint-disable-next-line no-shadow + $.fn.contextmenu = function(container, options) { + return this.each(function () { + $(this).data('contextmenu', new Menu(options, this, container)); + }); + }; + + if (!state.isYoutubeType()) { + state.el.find('video').contextmenu(state.el, options); + state.el.on('destroy', function () { + let contextmenu = $(this).find('video').data('contextmenu'); + if (contextmenu) { + contextmenu.destroy(); + } + }); + } + + return $.Deferred().resolve().promise(); +} + +export default VideoContextMenu diff --git a/xmodule/assets/video/public/js/09_bumper.js b/xmodule/assets/video/public/js/09_bumper.js new file mode 100644 index 000000000000..d568c5369bb5 --- /dev/null +++ b/xmodule/assets/video/public/js/09_bumper.js @@ -0,0 +1,108 @@ +'use strict'; + +/** + * VideoBumper module. + * @exports video/09_bumper.js + * @constructor + * @param {Object} player The player factory. + * @param {Object} state The object containing the state of the video + * @return {jquery Promise} + */ +let VideoBumper = function(player, state) { + if (!(this instanceof VideoBumper)) { + return new VideoBumper(player, state); + } + + _.bindAll( + this, 'showMainVideoHandler', 'destroy', 'skipByDuration', 'destroyAndResolve' + ); + this.dfd = $.Deferred(); + this.element = state.el; + this.element.addClass('is-bumper'); + this.player = player; + this.state = state; + this.doNotShowAgain = false; + this.state.videoBumper = this; + this.bindHandlers(); + this.initialize(); + this.maxBumperDuration = 35; // seconds +}; + +VideoBumper.prototype = { + initialize: function() { + this.player(); + }, + + getPromise: function() { + return this.dfd.promise(); + }, + + showMainVideoHandler: function() { + this.state.storage.setItem('isBumperShown', true); + setTimeout(function() { + this.saveState(); + this.showMainVideo(); + }.bind(this), 20); + }, + + destroyAndResolve: function() { + this.destroy(); + this.dfd.resolve(); + }, + + showMainVideo: function() { + if (this.state.videoPlayer) { + this.destroyAndResolve(); + } else { + this.state.el.on('initialize', this.destroyAndResolve); + } + }, + + skip: function() { + this.element.trigger('skip', [this.doNotShowAgain]); + this.showMainVideoHandler(); + }, + + skipAndDoNotShowAgain: function() { + this.doNotShowAgain = true; + this.skip(); + }, + + skipByDuration: function(event, time) { + if (time > this.maxBumperDuration) { + this.element.trigger('ended'); + } + }, + + bindHandlers: function() { + let events = ['ended', 'error'].join(' '); + this.element.on(events, this.showMainVideoHandler); + this.element.on('timeupdate', this.skipByDuration); + }, + + saveState: function() { + let info = {bumper_last_view_date: true}; + if (this.doNotShowAgain) { + _.extend(info, {bumper_do_not_show_again: true}); + } + if (this.state.videoSaveStatePlugin) { + this.state.videoSaveStatePlugin.saveState(true, info); + } + }, + + destroy: function() { + let events = ['ended', 'error'].join(' '); + this.element.off(events, this.showMainVideoHandler); + this.element.off({ + timeupdate: this.skipByDuration, + initialize: this.destroyAndResolve + }); + this.element.removeClass('is-bumper'); + if (_.isFunction(this.state.videoPlayer.destroy)) { + this.state.videoPlayer.destroy(); + } + delete this.state.videoBumper; + } +}; + +export default VideoBumper; diff --git a/xmodule/assets/video/public/js/09_completion.js b/xmodule/assets/video/public/js/09_completion.js new file mode 100644 index 000000000000..8d7faeb71b54 --- /dev/null +++ b/xmodule/assets/video/public/js/09_completion.js @@ -0,0 +1,201 @@ +'use strict'; + + + +/** + * Completion handler + * @exports video/09_completion.js + * @constructor + * @param {Object} state The object containing the state of the video + * @return {jquery Promise} + */ +let VideoCompletionHandler = function(state) { + if (!(this instanceof VideoCompletionHandler)) { + return new VideoCompletionHandler(state); + } + this.state = state; + this.state.completionHandler = this; + this.initialize(); + return $.Deferred().resolve().promise(); +}; + +VideoCompletionHandler.prototype = { + + /** Tears down the VideoCompletionHandler. + * + * * Removes backreferences from this.state to this. + * * Turns off signal handlers. + */ + destroy: function() { + this.el.remove(); + this.el.off('timeupdate.completion'); + this.el.off('ended.completion'); + delete this.state.completionHandler; + }, + + /** Initializes the VideoCompletionHandler. + * + * This sets all the instance variables needed to perform + * completion calculations. + */ + initialize: function() { + // Attributes with "Time" in the name refer to the number of seconds since + // the beginning of the video, except for lastSentTime, which refers to a + // timestamp in seconds since the Unix epoch. + this.lastSentTime = undefined; + this.isComplete = false; + this.completionPercentage = this.state.config.completionPercentage; + this.startTime = this.state.config.startTime; + this.endTime = this.state.config.endTime; + this.isEnabled = this.state.config.completionEnabled; + if (this.endTime) { + this.completeAfterTime = this.calculateCompleteAfterTime(this.startTime, this.endTime); + } + if (this.isEnabled) { + this.bindHandlers(); + } + }, + + /** Bind event handler callbacks. + * + * When ended is triggered, mark the video complete + * unconditionally. + * + * When timeupdate is triggered, check to see if the user has + * passed the completeAfterTime in the video, and if so, mark the + * video complete. + * + * When destroy is triggered, clean up outstanding resources. + */ + bindHandlers: function() { + let self = this; + + /** Event handler to check if the video is complete, and submit + * a completion if it is. + * + * If the timeupdate handler doesn't fire after the required + * percentage, this will catch any fully complete videos. + */ + this.state.el.on('ended.completion', function() { + self.handleEnded(); + }); + + /** Event handler to check video progress, and mark complete if + * greater than completionPercentage + */ + this.state.el.on('timeupdate.completion', function(ev, currentTime) { + self.handleTimeUpdate(currentTime); + }); + + /** Event handler to receive youtube metadata (if we even are a youtube link), + * and mark complete, if youtube will insist on hosting the video itself. + */ + this.state.el.on('metadata_received', function() { + self.checkMetadata(); + }); + + /** Event handler to clean up resources when the video player + * is destroyed. + */ + this.state.el.off('destroy', this.destroy); + }, + + /** Handler to call when the ended event is triggered */ + handleEnded: function() { + if (this.isComplete) { + return; + } + this.markCompletion(); + }, + + /** Handler to call when a timeupdate event is triggered */ + handleTimeUpdate: function(currentTime) { + let duration; + if (this.isComplete) { + return; + } + if (this.lastSentTime !== undefined && currentTime - this.lastSentTime < this.repostDelaySeconds()) { + // Throttle attempts to submit in case of network issues + return; + } + if (this.completeAfterTime === undefined) { + // Duration is not available at initialization time + duration = this.state.videoPlayer.duration(); + if (!duration) { + // duration is not yet set. Wait for another event, + // or fall back to 'ended' handler. + return; + } + this.completeAfterTime = this.calculateCompleteAfterTime(this.startTime, duration); + } + + if (currentTime > this.completeAfterTime) { + this.markCompletion(currentTime); + } + }, + + /** Handler to call when youtube metadata is received */ + checkMetadata: function() { + let metadata = this.state.metadata[this.state.youtubeId()]; + + // https://developers.google.com/youtube/v3/docs/videos#contentDetails.contentRating.ytRating + if (metadata && metadata.contentRating && metadata.contentRating.ytRating === 'ytAgeRestricted') { + // Age-restricted videos won't play in embedded players. Instead, they ask you to watch it on + // youtube itself. Which means we can't notice if they complete it. Rather than leaving an + // incompletable video in the course, let's just mark it complete right now. + if (!this.isComplete) { + this.markCompletion(); + } + } + }, + + /** Submit completion to the LMS */ + markCompletion: function(currentTime) { + let self = this; + let errmsg; + this.isComplete = true; + this.lastSentTime = currentTime; + this.state.el.trigger('complete'); + if (this.state.config.publishCompletionUrl) { + $.ajax({ + type: 'POST', + url: this.state.config.publishCompletionUrl, + contentType: 'application/json', + dataType: 'json', + data: JSON.stringify({completion: 1.0}), + success: function() { + self.state.el.off('timeupdate.completion'); + self.state.el.off('ended.completion'); + }, + error: function(xhr) { + /* eslint-disable no-console */ + self.complete = false; + errmsg = 'Failed to submit completion'; + if (xhr.responseJSON !== undefined) { + errmsg += ': ' + xhr.responseJSON.error; + } + console.warn(errmsg); + /* eslint-enable no-console */ + } + }); + } else { + /* eslint-disable no-console */ + console.warn('publishCompletionUrl not defined'); + /* eslint-enable no-console */ + } + }, + + /** Determine what point in the video (in seconds from the + * beginning) counts as complete. + */ + calculateCompleteAfterTime: function(startTime, endTime) { + return startTime + (endTime - startTime) * this.completionPercentage; + }, + + /** How many seconds to wait after a POST fails to try again. */ + repostDelaySeconds: function() { + return 3.0; + } +}; + +export default VideoCompletionHandler; diff --git a/xmodule/assets/video/public/js/09_events_bumper_plugin.js b/xmodule/assets/video/public/js/09_events_bumper_plugin.js new file mode 100644 index 000000000000..6e1b3fef3d15 --- /dev/null +++ b/xmodule/assets/video/public/js/09_events_bumper_plugin.js @@ -0,0 +1,112 @@ +'use strict'; + +import _ from 'underscore'; + + +/** + * Events module. + * @exports video/09_events_bumper_plugin.js + * @constructor + * @param {Object} state The object containing the state of the video + * @param {Object} i18n The object containing strings with translations. + * @param {Object} options + * @return {jquery Promise} + */ +let EventsBumperPlugin = function(state, i18n, options) { + if (!(this instanceof EventsBumperPlugin)) { + return new EventsBumperPlugin(state, i18n, options); + } + + _.bindAll(this, 'onReady', 'onPlay', 'onEnded', 'onShowLanguageMenu', 'onHideLanguageMenu', 'onSkip', + 'onShowCaptions', 'onHideCaptions', 'destroy'); + this.state = state; + this.options = _.extend({}, options); + this.state.videoEventsBumperPlugin = this; + this.i18n = i18n; + this.initialize(); + + return $.Deferred().resolve().promise(); +}; + +EventsBumperPlugin.moduleName = 'EventsBumperPlugin'; +EventsBumperPlugin.prototype = { + destroy: function() { + this.state.el.off(this.events); + delete this.state.videoEventsBumperPlugin; + }, + + initialize: function() { + this.events = { + ready: this.onReady, + play: this.onPlay, + 'ended stop': this.onEnded, + skip: this.onSkip, + 'language_menu:show': this.onShowLanguageMenu, + 'language_menu:hide': this.onHideLanguageMenu, + 'captions:show': this.onShowCaptions, + 'captions:hide': this.onHideCaptions, + destroy: this.destroy + }; + this.bindHandlers(); + }, + + bindHandlers: function() { + this.state.el.on(this.events); + }, + + onReady: function() { + this.log('edx.video.bumper.loaded'); + }, + + onPlay: function() { + this.log('edx.video.bumper.played', {currentTime: this.getCurrentTime()}); + }, + + onEnded: function() { + this.log('edx.video.bumper.stopped', {currentTime: this.getCurrentTime()}); + }, + + onSkip: function(event, doNotShowAgain) { + let info = {currentTime: this.getCurrentTime()}; + let eventName = 'edx.video.bumper.' + (doNotShowAgain ? 'dismissed' : 'skipped'); + this.log(eventName, info); + }, + + onShowLanguageMenu: function() { + this.log('edx.video.bumper.transcript.menu.shown'); + }, + + onHideLanguageMenu: function() { + this.log('edx.video.bumper.transcript.menu.hidden'); + }, + + onShowCaptions: function() { + this.log('edx.video.bumper.transcript.shown', {currentTime: this.getCurrentTime()}); + }, + + onHideCaptions: function() { + this.log('edx.video.bumper.transcript.hidden', {currentTime: this.getCurrentTime()}); + }, + + getCurrentTime: function() { + let player = this.state.videoPlayer; + return player ? player.currentTime : 0; + }, + + getDuration: function() { + let player = this.state.videoPlayer; + return player ? player.duration() : 0; + }, + + log: function(eventName, data) { + let logInfo = _.extend({ + host_component_id: this.state.id, + bumper_id: this.state.config.sources[0] || '', + duration: this.getDuration(), + code: 'html5' + }, data, this.options.data); + Logger.log(eventName, logInfo); + } +}; + +export default EventsBumperPlugin; diff --git a/xmodule/assets/video/public/js/09_events_plugin.js b/xmodule/assets/video/public/js/09_events_plugin.js new file mode 100644 index 000000000000..2febb99793c8 --- /dev/null +++ b/xmodule/assets/video/public/js/09_events_plugin.js @@ -0,0 +1,177 @@ +'use strict'; + +import _ from 'underscore'; + + +/** + * Events module. + * @exports video/09_events_plugin.js + * @constructor + * @param {Object} state The object containing the state of the video + * @param {Object} i18n The object containing strings with translations. + * @param {Object} options + * @return {jquery Promise} + */ +let EventsPlugin = function(state, i18n, options) { + if (!(this instanceof EventsPlugin)) { + return new EventsPlugin(state, i18n, options); + } + + _.bindAll(this, 'onReady', 'onPlay', 'onPause', 'onComplete', 'onEnded', 'onSeek', + 'onSpeedChange', 'onAutoAdvanceChange', 'onShowLanguageMenu', 'onHideLanguageMenu', + 'onSkip', 'onShowTranscript', 'onHideTranscript', 'onShowCaptions', 'onHideCaptions', + 'destroy'); + + this.state = state; + this.options = _.extend({}, options); + this.state.videoEventsPlugin = this; + this.i18n = i18n; + this.initialize(); + + return $.Deferred().resolve().promise(); +}; + +EventsPlugin.moduleName = 'EventsPlugin'; +EventsPlugin.prototype = { + destroy: function() { + this.state.el.off(this.events); + delete this.state.videoEventsPlugin; + }, + + initialize: function() { + this.events = { + ready: this.onReady, + play: this.onPlay, + pause: this.onPause, + complete: this.onComplete, + 'ended stop': this.onEnded, + seek: this.onSeek, + skip: this.onSkip, + speedchange: this.onSpeedChange, + autoadvancechange: this.onAutoAdvanceChange, + 'language_menu:show': this.onShowLanguageMenu, + 'language_menu:hide': this.onHideLanguageMenu, + 'transcript:show': this.onShowTranscript, + 'transcript:hide': this.onHideTranscript, + 'captions:show': this.onShowCaptions, + 'captions:hide': this.onHideCaptions, + destroy: this.destroy + }; + this.bindHandlers(); + this.emitPlayVideoEvent = true; + }, + + bindHandlers: function() { + this.state.el.on(this.events); + }, + + onReady: function() { + this.log('load_video'); + }, + + onPlay: function() { + if (this.emitPlayVideoEvent) { + this.log('play_video', {currentTime: this.getCurrentTime()}); + this.emitPlayVideoEvent = false; + } + }, + + onPause: function() { + this.log('pause_video', {currentTime: this.getCurrentTime()}); + this.emitPlayVideoEvent = true; + }, + + onComplete: function() { + this.log('complete_video', {currentTime: this.getCurrentTime()}); + }, + + onEnded: function() { + this.log('stop_video', {currentTime: this.getCurrentTime()}); + this.emitPlayVideoEvent = true; + }, + + onSkip: function(event, doNotShowAgain) { + let info = {currentTime: this.getCurrentTime()}; + let eventName = doNotShowAgain ? 'do_not_show_again_video' : 'skip_video'; + this.log(eventName, info); + }, + + onSeek: function(event, time, oldTime, type) { + this.log('seek_video', { + old_time: oldTime, + new_time: time, + type: type + }); + this.emitPlayVideoEvent = true; + }, + + onSpeedChange: function(event, newSpeed, oldSpeed) { + this.log('speed_change_video', { + current_time: this.getCurrentTime(), + old_speed: this.state.speedToString(oldSpeed), + new_speed: this.state.speedToString(newSpeed) + }); + }, + + onAutoAdvanceChange: function(event, enabled) { + this.log('auto_advance_change_video', { + enabled: enabled + }); + }, + + onShowLanguageMenu: function() { + this.log('edx.video.language_menu.shown'); + }, + + onHideLanguageMenu: function() { + this.log('edx.video.language_menu.hidden', {language: this.getCurrentLanguage()}); + }, + + onShowTranscript: function() { + this.log('show_transcript', {current_time: this.getCurrentTime()}); + }, + + onHideTranscript: function() { + this.log('hide_transcript', {current_time: this.getCurrentTime()}); + }, + + onShowCaptions: function() { + this.log('edx.video.closed_captions.shown', {current_time: this.getCurrentTime()}); + }, + + onHideCaptions: function() { + this.log('edx.video.closed_captions.hidden', {current_time: this.getCurrentTime()}); + }, + + getCurrentTime: function() { + let player = this.state.videoPlayer; + let startTime = this.state.config.startTime; + let currentTime = player ? player.currentTime : 0; + // if video didn't start from 0(it's a subsection of video), subtract the additional time at start + if (startTime) { + currentTime = currentTime ? currentTime - startTime : 0; + } + return currentTime; + }, + + getCurrentLanguage: function() { + let language = this.state.lang; + return language; + }, + + log: function(eventName, data) { + // use startTime and endTime to calculate the duration to handle the case where only a subsection of video is used + let endTime = this.state.config.endTime || this.state.duration; + let startTime = this.state.config.startTime || 0; + + let logInfo = _.extend({ + id: this.state.id, + // eslint-disable-next-line no-nested-ternary + code: this.state.isYoutubeType() ? this.state.youtubeId() : this.state.canPlayHLS ? 'hls' : 'html5', + duration: endTime - startTime + }, data, this.options.data); + Logger.log(eventName, logInfo); + } +}; + +export default EventsPlugin; diff --git a/xmodule/assets/video/public/js/09_play_pause_control.js b/xmodule/assets/video/public/js/09_play_pause_control.js new file mode 100644 index 000000000000..c0e0844641b8 --- /dev/null +++ b/xmodule/assets/video/public/js/09_play_pause_control.js @@ -0,0 +1,96 @@ +'use strict'; + +import _ from 'underscore'; + + +/** + * Play/pause control module. + * @exports video/09_play_pause_control.js + * @constructor + * @param {Object} state The object containing the state of the video + * @param {Object} i18n The object containing strings with translations. + * @return {jquery Promise} + */ +let PlayPauseControl = function(state, i18n) { + if (!(this instanceof PlayPauseControl)) { + return new PlayPauseControl(state, i18n); + } + + _.bindAll(this, 'play', 'pause', 'onClick', 'destroy'); + this.state = state; + this.state.videoPlayPauseControl = this; + this.i18n = i18n; + this.initialize(); + + return $.Deferred().resolve().promise(); +}; + +PlayPauseControl.prototype = { + template: [ + '' + ].join(''), + + destroy: function() { + this.el.remove(); + this.state.el.off('destroy', this.destroy); + delete this.state.videoPlayPauseControl; + }, + + /** Initializes the module. */ + initialize: function() { + this.el = $(this.template); + this.render(); + this.bindHandlers(); + }, + + /** + * Creates any necessary DOM elements, attach them, and set their, + * initial configuration. + */ + render: function() { + this.state.el.find('.vcr').prepend(this.el); + }, + + /** Bind any necessary function callbacks to DOM events. */ + bindHandlers: function() { + this.el.on({ + click: this.onClick + }); + this.state.el.on({ + play: this.play, + 'pause ended': this.pause, + destroy: this.destroy + }); + }, + + onClick: function(event) { + event.preventDefault(); + this.state.videoCommands.execute('togglePlayback'); + }, + + play: function() { + this.el + .addClass('pause') + .removeClass('play') + .attr({title: gettext('Pause'), 'aria-label': gettext('Pause')}) + .find('.icon') + .removeClass('fa-play') + .addClass('fa-pause'); + }, + + pause: function() { + this.el + .removeClass('pause') + .addClass('play') + .attr({title: gettext('Play'), 'aria-label': gettext('Play')}) + .find('.icon') + .removeClass('fa-pause') + .addClass('fa-play'); + } +}; + +export default PlayPauseControl; diff --git a/xmodule/assets/video/public/js/09_play_placeholder.js b/xmodule/assets/video/public/js/09_play_placeholder.js new file mode 100644 index 000000000000..47052ea06495 --- /dev/null +++ b/xmodule/assets/video/public/js/09_play_placeholder.js @@ -0,0 +1,84 @@ +'use strict'; + +/** + * Play placeholder control module. + * @exports video/09_play_placeholder.js + * @constructor + * @param {Object} state The object containing the state of the video + * @param {Object} i18n The object containing strings with translations. + * @return {jquery Promise} + */ +let PlayPlaceholder = function(state, i18n) { + if (!(this instanceof PlayPlaceholder)) { + return new PlayPlaceholder(state, i18n); + } + + _.bindAll(this, 'onClick', 'hide', 'show', 'destroy'); + this.state = state; + this.state.videoPlayPlaceholder = this; + this.i18n = i18n; + this.initialize(); + + return $.Deferred().resolve().promise(); +}; + +PlayPlaceholder.prototype = { + destroy: function() { + this.el.off('click', this.onClick); + this.state.el.on({ + destroy: this.destroy, + play: this.hide, + 'ended pause': this.show + }); + this.hide(); + delete this.state.videoPlayPlaceholder; + }, + + /** + * Indicates whether the placeholder should be shown. We display it + * for html5 videos on iPad and Android devices. + * @return {Boolean} + */ + shouldBeShown: function() { + return /iPad|Android/i.test(this.state.isTouch[0]) && !this.state.isYoutubeType(); + }, + + /** Initializes the module. */ + initialize: function() { + if (!this.shouldBeShown()) { + return false; + } + + this.el = this.state.el.find('.btn-play'); + this.bindHandlers(); + this.show(); + }, + + /** Bind any necessary function callbacks to DOM events. */ + bindHandlers: function() { + this.el.on('click', this.onClick); + this.state.el.on({ + destroy: this.destroy, + play: this.hide, + 'ended pause': this.show + }); + }, + + onClick: function() { + this.state.videoCommands.execute('play'); + }, + + hide: function() { + this.el + .addClass('is-hidden') + .attr({'aria-hidden': 'true', tabindex: -1}); + }, + + show: function() { + this.el + .removeClass('is-hidden') + .attr({'aria-hidden': 'false', tabindex: 0}); + } +}; + +export default PlayPlaceholder; diff --git a/xmodule/assets/video/public/js/09_play_skip_control.js b/xmodule/assets/video/public/js/09_play_skip_control.js new file mode 100644 index 000000000000..f1cb1bdb92de --- /dev/null +++ b/xmodule/assets/video/public/js/09_play_skip_control.js @@ -0,0 +1,86 @@ +'use strict'; + +/** + * Play/skip control module. + * @exports video/09_play_skip_control.js + * @constructor + * @param {Object} state The object containing the state of the video + * @param {Object} i18n The object containing strings with translations. + * @return {jquery Promise} + */ +let PlaySkipControl = function(state, i18n) { + if (!(this instanceof PlaySkipControl)) { + return new PlaySkipControl(state, i18n); + } + + _.bindAll(this, 'play', 'onClick', 'destroy'); + this.state = state; + this.state.videoPlaySkipControl = this; + this.i18n = i18n; + this.initialize(); + + return $.Deferred().resolve().promise(); +}; + +PlaySkipControl.prototype = { + template: [ + '' + ].join(''), + + destroy: function() { + this.el.remove(); + this.state.el.off('destroy', this.destroy); + delete this.state.videoPlaySkipControl; + }, + + /** Initializes the module. */ + initialize: function() { + this.el = $(this.template); + this.render(); + this.bindHandlers(); + }, + + /** + * Creates any necessary DOM elements, attach them, and set their, + * initial configuration. + */ + render: function() { + this.state.el.find('.vcr').prepend(this.el); + }, + + /** Bind any necessary function callbacks to DOM events. */ + bindHandlers: function() { + this.el.on('click', this.onClick); + this.state.el.on({ + play: this.play, + destroy: this.destroy + }); + }, + + onClick: function(event) { + event.preventDefault(); + if (this.state.videoPlayer.isPlaying()) { + this.state.videoCommands.execute('skip'); + } else { + this.state.videoCommands.execute('play'); + } + }, + + play: function() { + this.el + .removeClass('play') + .addClass('skip') + .attr('title', gettext('Skip')) + .find('.icon') + .removeClass('fa-play') + .addClass('fa-step-forward'); + // Disable possibility to pause the video. + this.state.el.find('video').off('click'); + } +}; + +export default PlaySkipControl; diff --git a/xmodule/assets/video/public/js/09_poster.js b/xmodule/assets/video/public/js/09_poster.js new file mode 100644 index 000000000000..97e0cf388fd2 --- /dev/null +++ b/xmodule/assets/video/public/js/09_poster.js @@ -0,0 +1,62 @@ +'use strict'; + +import _ from 'underscore'; + + +let VideoPoster = function(element, options) { + if (!(this instanceof VideoPoster)) { + return new VideoPoster(element, options); + } + + _.bindAll(this, 'onClick', 'destroy'); + this.element = element; + this.container = element.find('.video-player'); + this.options = options || {}; + this.initialize(); +}; + +VideoPoster.moduleName = 'Poster'; +VideoPoster.prototype = { + template: _.template([ + '
        ', + '', + '
        ' + ].join('')), + + initialize: function() { + this.el = $(this.template({ + url: this.options.poster.url, + type: this.options.poster.type + })); + this.element.addClass('is-pre-roll'); + this.render(); + this.bindHandlers(); + }, + + bindHandlers: function() { + this.el.on('click', this.onClick); + this.element.on('destroy', this.destroy); + }, + + render: function() { + this.container.append(this.el); + }, + + onClick: function() { + if (_.isFunction(this.options.onClick)) { + this.options.onClick(); + } + this.destroy(); + }, + + destroy: function() { + this.element.off('destroy', this.destroy).removeClass('is-pre-roll'); + this.el.remove(); + } +}; + +export default VideoPoster; diff --git a/xmodule/assets/video/public/js/09_save_state_plugin.js b/xmodule/assets/video/public/js/09_save_state_plugin.js new file mode 100644 index 000000000000..c5de62628d9d --- /dev/null +++ b/xmodule/assets/video/public/js/09_save_state_plugin.js @@ -0,0 +1,131 @@ +'use strict'; + +import _ from 'underscore'; +import { convert, format, formatFull } from './utils/time.js'; + + +/** + * Save state module. + * @exports video/09_save_state_plugin.js + * @constructor + * @param {Object} state The object containing the state of the video + * @param {Object} i18n The object containing strings with translations. + * @param {Object} options + * @return {jquery Promise} + */ +let SaveStatePlugin = function(state, i18n, options) { + if (!(this instanceof SaveStatePlugin)) { + return new SaveStatePlugin(state, i18n, options); + } + + _.bindAll(this, 'onSpeedChange', 'onAutoAdvanceChange', 'saveStateHandler', 'bindUnloadHandler', 'onUnload', + 'onYoutubeAvailability', 'onLanguageChange', 'destroy'); + this.state = state; + this.options = _.extend({events: []}, options); + this.state.videoSaveStatePlugin = this; + this.i18n = i18n; + this.initialize(); + + return $.Deferred().resolve().promise(); +}; + +SaveStatePlugin.moduleName = 'SaveStatePlugin'; +SaveStatePlugin.prototype = { + destroy: function() { + this.state.el.off(this.events).off('destroy', this.destroy); + $(window).off('unload', this.onUnload); + delete this.state.videoSaveStatePlugin; + }, + + initialize: function() { + this.events = { + speedchange: this.onSpeedChange, + autoadvancechange: this.onAutoAdvanceChange, + play: this.bindUnloadHandler, + 'pause destroy': this.saveStateHandler, + 'language_menu:change': this.onLanguageChange, + youtube_availability: this.onYoutubeAvailability + }; + this.bindHandlers(); + }, + + bindHandlers: function() { + if (this.options.events.length) { + _.each(this.options.events, function(eventName) { + let callback; + if (_.has(this.events, eventName)) { + callback = this.events[eventName]; + this.state.el.on(eventName, callback); + } + }, this); + } else { + this.state.el.on(this.events); + } + this.state.el.on('destroy', this.destroy); + }, + + bindUnloadHandler: _.once(function() { + $(window).on('unload.video', this.onUnload); + }), + + onSpeedChange: function(event, newSpeed) { + this.saveState(true, {speed: newSpeed}); + this.state.storage.setItem('speed', newSpeed, true); + this.state.storage.setItem('general_speed', newSpeed); + }, + + onAutoAdvanceChange: function(event, enabled) { + this.saveState(true, {auto_advance: enabled}); + this.state.storage.setItem('auto_advance', enabled); + }, + + saveStateHandler: function() { + this.saveState(true); + }, + + onUnload: function() { + this.saveState(); + }, + + onLanguageChange: function(event, langCode) { + this.state.storage.setItem('language', langCode); + }, + + onYoutubeAvailability: function(event, youtubeIsAvailable) { + // Compare what the client-side code has determined Youtube + // availability to be (true/false) vs. what the LMS recorded for + // this user. The LMS will assume YouTube is available by default. + if (youtubeIsAvailable !== this.state.config.recordedYoutubeIsAvailable) { + this.saveState(true, {youtube_is_available: youtubeIsAvailable}); + } + }, + + saveState: function(async, data) { + if (this.state.config.saveStateEnabled) { + if (!($.isPlainObject(data))) { + data = { + saved_video_position: this.state.videoPlayer.currentTime + }; + } + + if (data.speed) { + this.state.storage.setItem('speed', data.speed, true); + } + + if (_.has(data, 'saved_video_position')) { + this.state.storage.setItem('savedVideoPosition', data.saved_video_position, true); + data.saved_video_position = formatFull(data.saved_video_position); + } + + $.ajax({ + url: this.state.config.saveStateUrl, + type: 'POST', + async: !!async, + dataType: 'json', + data: data + }); + } + } +}; + +export default SaveStatePlugin; diff --git a/xmodule/assets/video/public/js/09_skip_control.js b/xmodule/assets/video/public/js/09_skip_control.js new file mode 100644 index 000000000000..17138e5f8882 --- /dev/null +++ b/xmodule/assets/video/public/js/09_skip_control.js @@ -0,0 +1,72 @@ +'use strict'; + +import _ from 'underscore'; + + +/** + * Video skip control module. + * @exports video/09_skip_control.js + * @constructor + * @param {Object} state The object containing the state of the video + * @param {Object} i18n The object containing strings with translations. + * @return {jquery Promise} + */ +let SkipControl = function(state, i18n) { + if (!(this instanceof SkipControl)) { + return new SkipControl(state, i18n); + } + + _.bindAll(this, 'onClick', 'render', 'destroy'); + this.state = state; + this.state.videoSkipControl = this; + this.i18n = i18n; + this.initialize(); + + return $.Deferred().resolve().promise(); +}; + +SkipControl.prototype = { + template: [ + '' + ].join(''), + + destroy: function() { + this.el.remove(); + this.state.el.off('.skip'); + delete this.state.videoSkipControl; + }, + + /** Initializes the module. */ + initialize: function() { + this.el = $(this.template); + this.bindHandlers(); + }, + + /** + * Creates any necessary DOM elements, attach them, and set their, + * initial configuration. + */ + render: function() { + this.state.el.find('.vcr .control').after(this.el); + }, + + /** Bind any necessary function callbacks to DOM events. */ + bindHandlers: function() { + this.el.on('click', this.onClick); + this.state.el.on({ + 'play.skip': _.once(this.render), + 'destroy.skip': this.destroy + }); + }, + + onClick: function(event) { + event.preventDefault(); + this.state.videoCommands.execute('skip', true); + } +}; + +export default SkipControl; diff --git a/xmodule/assets/video/public/js/09_video_caption.js b/xmodule/assets/video/public/js/09_video_caption.js new file mode 100644 index 000000000000..309abb70e8ea --- /dev/null +++ b/xmodule/assets/video/public/js/09_video_caption.js @@ -0,0 +1,1459 @@ +// VideoCaption module. + +import Sjson from './00_sjson.js'; +import AsyncProcess from './00_async_process.js'; +import HtmlUtils from 'edx-ui-toolkit/js/utils/html-utils'; +import Draggabilly from 'draggabilly'; +import { convert } from './utils/time.js'; +import _ from 'underscore'; + +'use strict'; + +/** + * @desc VideoCaption module exports a function. + * + * @type {function} + * @access public + * + * @param {object} state - The object containing the state of the video + * player. All other modules, their parameters, public variables, etc. + * are available via this object. + * + * @this {object} The global window object. + * + * @returns {jquery Promise} + */ +let VideoCaption = function(state) { + if (!(this instanceof VideoCaption)) { + return new VideoCaption(state); + } + + _.bindAll(this, 'toggleTranscript', 'onMouseEnter', 'onMouseLeave', 'onMovement', + 'onContainerMouseEnter', 'onContainerMouseLeave', 'fetchCaption', + 'onResize', 'pause', 'play', 'onCaptionUpdate', 'onCaptionHandler', 'destroy', + 'handleKeypress', 'handleKeypressLink', 'openLanguageMenu', 'closeLanguageMenu', + 'previousLanguageMenuItem', 'nextLanguageMenuItem', 'handleCaptionToggle', + 'showClosedCaptions', 'hideClosedCaptions', 'toggleClosedCaptions', + 'updateCaptioningCookie', 'handleCaptioningCookie', 'handleTranscriptToggle', + 'listenForDragDrop', 'setTranscriptVisibility', 'updateTranscriptCookie', + 'updateGoogleDisclaimer', 'toggleGoogleDisclaimer', 'updateProblematicCaptionsContent' + ); + + this.state = state; + this.state.videoCaption = this; + this.renderElements(); + this.handleCaptioningCookie(); + this.setTranscriptVisibility(); + this.listenForDragDrop(); + + return $.Deferred().resolve().promise(); +}; + +VideoCaption.prototype = { + + destroy: function() { + this.state.el + .off({ + 'caption:fetch': this.fetchCaption, + 'caption:resize': this.onResize, + 'caption:update': this.onCaptionUpdate, + ended: this.pause, + fullscreen: this.onResize, + pause: this.pause, + play: this.play, + destroy: this.destroy + }) + .removeClass('is-captions-rendered'); + if (this.fetchXHR && this.fetchXHR.abort) { + this.fetchXHR.abort(); + } + if (this.availableTranslationsXHR && this.availableTranslationsXHR.abort) { + this.availableTranslationsXHR.abort(); + } + this.subtitlesEl.remove(); + this.container.remove(); + delete this.state.videoCaption; + }, + /** + * @desc Initiate rendering of elements, and set their initial configuration. + * + */ + renderElements: function() { + let languages = this.state.config.transcriptLanguages; + + let langHtml = HtmlUtils.interpolateHtml( + HtmlUtils.HTML( + [ + '
        ', + '', + '', + '', + '
        ' + ].join('')), + { + langTitle: gettext('Open language menu'), + courseId: this.state.id + } + ); + + let subtitlesHtml = HtmlUtils.interpolateHtml( + HtmlUtils.HTML( + [ + '
        ', + '

        ', + '
          ', + '
          ' + ].join('')), + { + courseId: this.state.id, + courseLang: this.state.lang + } + ); + + this.loaded = false; + this.subtitlesEl = $(HtmlUtils.ensureHtml(subtitlesHtml).toString()); + this.subtitlesMenuEl = this.subtitlesEl.find('.subtitles-menu'); + this.container = $(HtmlUtils.ensureHtml(langHtml).toString()); + this.captionControlEl = this.container.find('.toggle-captions'); + this.captionDisplayEl = this.state.el.find('.closed-captions'); + this.transcriptControlEl = this.container.find('.toggle-transcript'); + this.languageChooserEl = this.container.find('.lang'); + this.menuChooserEl = this.languageChooserEl.parent(); + + if (_.keys(languages).length) { + this.renderLanguageMenu(languages); + this.fetchCaption(); + } + }, + + /** + * @desc Bind any necessary function callbacks to DOM events (click, + * mousemove, etc.). + * + */ + bindHandlers: function() { + let state = this.state, + events = [ + 'mouseover', 'mouseout', 'mousedown', 'click', 'focus', 'blur', + 'keydown' + ].join(' '); + + this.captionControlEl.on({ + click: this.toggleClosedCaptions, + keydown: this.handleCaptionToggle + }); + this.transcriptControlEl.on({ + click: this.toggleTranscript, + keydown: this.handleTranscriptToggle + }); + this.subtitlesMenuEl.on({ + mouseenter: this.onMouseEnter, + mouseleave: this.onMouseLeave, + mousemove: this.onMovement, + mousewheel: this.onMovement, + DOMMouseScroll: this.onMovement + }) + .on(events, 'span[data-index]', this.onCaptionHandler); + this.container.on({ + mouseenter: this.onContainerMouseEnter, + mouseleave: this.onContainerMouseLeave + }); + + if (this.showLanguageMenu) { + this.languageChooserEl.on({ + keydown: this.handleKeypress + }, '.language-menu'); + + this.languageChooserEl.on({ + keydown: this.handleKeypressLink + }, '.control-lang'); + } + + state.el + .on({ + 'caption:fetch': this.fetchCaption, + 'caption:resize': this.onResize, + 'caption:update': this.onCaptionUpdate, + ended: this.pause, + fullscreen: this.onResize, + pause: this.pause, + play: this.play, + destroy: this.destroy + }); + + if ((state.videoType === 'html5') && (state.config.autohideHtml5)) { + this.subtitlesMenuEl.on('scroll', state.videoControl.showControls); + } + }, + + onCaptionUpdate: function(event, time) { + this.updatePlayTime(time); + }, + + handleCaptionToggle: function(event) { + let KEY = $.ui.keyCode, + keyCode = event.keyCode; + + switch (keyCode) { + case KEY.SPACE: + case KEY.ENTER: + event.preventDefault(); + this.toggleClosedCaptions(event); + // no default + } + }, + + handleTranscriptToggle: function(event) { + let KEY = $.ui.keyCode, + keyCode = event.keyCode; + + switch (keyCode) { + case KEY.SPACE: + case KEY.ENTER: + event.preventDefault(); + this.toggleTranscript(event); + // no default + } + }, + + handleKeypressLink: function(event) { + let KEY = $.ui.keyCode, + keyCode = event.keyCode, + focused, index, total; + + switch (keyCode) { + case KEY.UP: + event.preventDefault(); + focused = $(':focus').parent(); + index = this.languageChooserEl.find('li').index(focused); + total = this.languageChooserEl.find('li').size() - 1; + + this.previousLanguageMenuItem(event, index); + break; + + case KEY.DOWN: + event.preventDefault(); + focused = $(':focus').parent(); + index = this.languageChooserEl.find('li').index(focused); + total = this.languageChooserEl.find('li').size() - 1; + + this.nextLanguageMenuItem(event, index, total); + break; + + case KEY.ESCAPE: + this.closeLanguageMenu(event); + break; + + case KEY.ENTER: + case KEY.SPACE: + return true; + // no default + } + return true; + }, + + handleKeypress: function(event) { + let KEY = $.ui.keyCode, + keyCode = event.keyCode; + + switch (keyCode) { + // Handle keypresses + case KEY.ENTER: + case KEY.SPACE: + case KEY.UP: + event.preventDefault(); + this.openLanguageMenu(event); + break; + + case KEY.ESCAPE: + this.closeLanguageMenu(event); + break; + // no default + } + + return event.keyCode === KEY.TAB; + }, + + nextLanguageMenuItem: function(event, index, total) { + event.preventDefault(); + + if (event.altKey || event.shiftKey) { + return true; + } + + if (index === total) { + this.languageChooserEl + .find('.control-lang').first() + .focus(); + } else { + this.languageChooserEl + .find('li:eq(' + index + ')') + .next() + .find('.control-lang') + .focus(); + } + + return false; + }, + + previousLanguageMenuItem: function(event, index) { + event.preventDefault(); + + if (event.altKey || event.shiftKey) { + return true; + } + + if (index === 0) { + this.languageChooserEl + .find('.control-lang').last() + .focus(); + } else { + this.languageChooserEl + .find('li:eq(' + index + ')') + .prev() + .find('.control-lang') + .focus(); + } + + return false; + }, + + openLanguageMenu: function(event) { + let button = this.languageChooserEl, + menu = button.parent().find('.menu'); + + event.preventDefault(); + + button + .addClass('is-opened'); + + menu + .find('.control-lang').last() + .focus(); + }, + + closeLanguageMenu: function(event) { + let button = this.languageChooserEl; + event.preventDefault(); + + button + .removeClass('is-opened') + .find('.language-menu') + .focus(); + }, + + onCaptionHandler: function(event) { + switch (event.type) { + case 'mouseover': + case 'mouseout': + this.captionMouseOverOut(event); + break; + case 'mousedown': + this.captionMouseDown(event); + break; + case 'click': + this.captionClick(event); + break; + case 'focusin': + this.captionFocus(event); + break; + case 'focusout': + this.captionBlur(event); + break; + case 'keydown': + this.captionKeyDown(event); + break; + // no default + } + }, + + /** + * @desc Opens language menu. + * + * @param {jquery Event} event + */ + onContainerMouseEnter: function(event) { + event.preventDefault(); + $(event.currentTarget).find('.lang').addClass('is-opened'); + + // We only want to fire the analytics event if a menu is + // present instead of on the container hover, since it wraps + // the "CC" and "Transcript" buttons as well. + if ($(event.currentTarget).find('.lang').length) { + this.state.el.trigger('language_menu:show'); + } + }, + + /** + * @desc Closes language menu. + * + * @param {jquery Event} event + */ + onContainerMouseLeave: function(event) { + event.preventDefault(); + $(event.currentTarget).find('.lang').removeClass('is-opened'); + + // We only want to fire the analytics event if a menu is + // present instead of on the container hover, since it wraps + // the "CC" and "Transcript" buttons as well. + if ($(event.currentTarget).find('.lang').length) { + this.state.el.trigger('language_menu:hide'); + } + }, + + /** + * @desc Freezes moving of captions when mouse is over them. + * + * @param {jquery Event} event + */ + onMouseEnter: function() { + if (this.frozen) { + clearTimeout(this.frozen); + } + + this.frozen = setTimeout( + this.onMouseLeave, + this.state.config.captionsFreezeTime + ); + }, + + /** + * @desc Unfreezes moving of captions when mouse go out. + * + * @param {jquery Event} event + */ + onMouseLeave: function() { + if (this.frozen) { + clearTimeout(this.frozen); + } + + this.frozen = null; + + if (this.playing) { + this.scrollCaption(); + } + }, + + /** + * @desc Freezes moving of captions when mouse is moving over them. + * + * @param {jquery Event} event + */ + onMovement: function() { + this.onMouseEnter(); + }, + + /** + * @desc Gets the correct start and end times from the state configuration + * + * @returns {array} if [startTime, endTime] are defined + */ + getStartEndTimes: function() { + // due to the way config.startTime/endTime are + // processed in 03_video_player.js, we assume + // endTime can be an integer or null, + // and startTime is an integer > 0 + let config = this.state.config; + let startTime = config.startTime * 1000; + let endTime = (config.endTime !== null) ? config.endTime * 1000 : null; + return [startTime, endTime]; + }, + + /** + * @desc Gets captions within the start / end times stored within this.state.config + * + * @returns {object} {start, captions} parallel arrays of + * start times and corresponding captions + */ + getBoundedCaptions: function() { + // get start and caption. If startTime and endTime + // are specified, filter by that range. + let times = this.getStartEndTimes(); + // eslint-disable-next-line prefer-spread + let results = this.sjson.filter.apply(this.sjson, times); + let start = results.start; + let captions = results.captions; + + return { + start: start, + captions: captions + }; + }, + + /** + * @desc Sets whether or not the Google disclaimer should be shown based on captions + * being AI generated, and shows/hides based on the above and if ClosedCaptions are being shown. + * + * @param {array} captions List of captions for the video. + * + * @returns {boolean} + */ + updateGoogleDisclaimer: function(captions) { + const aIGeneratedSpanText = '\w+)["']/; + let self = this, + state = this.state, + aiGeneratedSpan = captions.find(caption => caption.includes(aIGeneratedSpanText)), + captionsAIGenerated = !(aiGeneratedSpan === undefined), + aiCaptionProviderIsGoogle = true; + + if (captionsAIGenerated) { + const providerMatch = aiProviderRegexp.exec(aiGeneratedSpan); + if (providerMatch !== null) { + aiCaptionProviderIsGoogle = providerMatch.groups['provider'] === 'gcp'; + } + // If there is no provider tag, it was generated before we added those, + // so it must be Google + } + // This field is whether or not, in general, this video should show the google disclaimer + self.shouldShowGoogleDisclaimer = captionsAIGenerated && aiCaptionProviderIsGoogle; + // Should we, right now, on load, show the google disclaimer + self.toggleGoogleDisclaimer(!self.hideCaptionsOnLoad && !state.captionsHidden); + }, + + /** + * @desc Show or hide the google translate disclaimer based on the passed param + * and whether or not we are currently showing a google translated transcript. + * @param {boolean} [show] Show if true, hide if false - if we are showing a google + * translated transcript. If not, this will always hide. + */ + toggleGoogleDisclaimer: function(show) { + let self = this, + state = this.state; + if (show && self.shouldShowGoogleDisclaimer) { + state.el.find('.google-disclaimer').show(); + } else { + state.el.find('.google-disclaimer').hide(); + } + }, + + /** + * @desc Replaces content in a caption + * + * @param {array} captions List of captions for the video. + * @param {string} content content to be replaced + * @param {string} replacementContent the replace string + * + * @returns {array} captions List of captions for the video. + */ + updateProblematicCaptionsContent: function(captions, content = '', replacementContent = '') { + let updatedCaptions = captions.map(caption => caption.replace(content, replacementContent)); + + return updatedCaptions; + }, + + /** + * @desc Fetch the caption file specified by the user. Upon successful + * receipt of the file, the captions will be rendered. + * @param {boolean} [fetchWithYoutubeId] Fetch youtube captions if true. + * @returns {boolean} + * true: The user specified a caption file. NOTE: if an error happens + * while the specified file is being retrieved (for example the + * file is missing on the server), this function will still return + * true. + * false: No caption file was specified, or an empty string was + * specified for the Youtube type player. + */ + fetchCaption: function(fetchWithYoutubeId) { + let self = this, + state = this.state, + language = state.getCurrentLanguage(), + url = state.config.transcriptTranslationUrl.replace('__lang__', language), + data, youtubeId; + + if (this.loaded) { + this.hideCaptions(false); + } + + if (this.fetchXHR && this.fetchXHR.abort) { + this.fetchXHR.abort(); + } + + if (state.videoType === 'youtube' || fetchWithYoutubeId) { + try { + youtubeId = state.youtubeId('1.0'); + } catch (err) { + youtubeId = null; + } + + if (!youtubeId) { + return false; + } + + data = {videoId: youtubeId}; + } + + state.el.removeClass('is-captions-rendered'); + // Fetch the captions file. If no file was specified, or if an error + // occurred, then we hide the captions panel, and the "Transcript" button + this.fetchXHR = $.ajaxWithPrefix({ + url: url, + notifyOnError: false, + data: data, + success: function(sjson) { + let results, start, captions; + self.sjson = new Sjson(sjson); + results = self.getBoundedCaptions(); + start = results.start; + captions = results.captions; + let contentToReplace = CAPTIONS_CONTENT_TO_REPLACE, + replacementContent = CAPTIONS_CONTENT_REPLACEMENT; + + captions = self.updateProblematicCaptionsContent(captions, contentToReplace, replacementContent); + + self.updateGoogleDisclaimer(captions); + + if (self.loaded) { + if (self.rendered) { + self.renderCaption(start, captions); + self.updatePlayTime(state.videoPlayer.currentTime); + } + } else { + if (state.isTouch) { + HtmlUtils.setHtml( + self.subtitlesEl.find('.subtitles-menu'), + HtmlUtils.joinHtml( + HtmlUtils.HTML('
        1. '), + gettext('Transcript will be displayed when you start playing the video.'), + HtmlUtils.HTML('
        2. ') + ) + ); + } else { + self.renderCaption(start, captions); + } + self.hideCaptions(self.hideCaptionsOnLoad); + HtmlUtils.append( + self.state.el.find('.video-wrapper').parent(), + HtmlUtils.HTML(self.subtitlesEl) + ); + HtmlUtils.append( + self.state.el.find('.secondary-controls'), + HtmlUtils.HTML(self.container) + ); + self.bindHandlers(); + } + + self.loaded = true; + }, + error: function(jqXHR, textStatus, errorThrown) { + let canFetchWithYoutubeId; + console.log('[Video info]: ERROR while fetching captions.'); + console.log( + '[Video info]: STATUS:', textStatus + + ', MESSAGE:', '' + errorThrown + ); + // If initial list of languages has more than 1 item, check + // for availability other transcripts. + // If player mode is html5 and there are no initial languages + // then try to fetch youtube version of transcript with + // youtubeId. + if (_.keys(state.config.transcriptLanguages).length > 1) { + self.fetchAvailableTranslations(); + } else if (!fetchWithYoutubeId && state.videoType === 'html5') { + canFetchWithYoutubeId = self.fetchCaption(true); + if (canFetchWithYoutubeId) { + console.log('[Video info]: Html5 mode fetching caption with youtubeId.'); // eslint-disable-line max-len, no-console + } else { + self.hideCaptions(true); + self.languageChooserEl.hide(); + self.hideClosedCaptions(); + } + } else { + self.hideCaptions(true); + self.languageChooserEl.hide(); + self.hideClosedCaptions(); + } + } + }); + + return true; + }, + + /** + * @desc Fetch the list of available language codes. Upon successful receipt + * the list of available languages will be updated. + * + * @returns {jquery Promise} + */ + fetchAvailableTranslations: function() { + let self = this, + state = this.state; + + this.availableTranslationsXHR = $.ajaxWithPrefix({ + url: state.config.transcriptAvailableTranslationsUrl, + notifyOnError: false, + success: function(response) { + let currentLanguages = state.config.transcriptLanguages, + newLanguages = _.pick(currentLanguages, response); + + // Update property with available currently translations. + state.config.transcriptLanguages = newLanguages; + // Remove an old language menu. + self.container.find('.langs-list').remove(); + + if (_.keys(newLanguages).length) { + self.renderLanguageMenu(newLanguages); + } + }, + error: function() { + self.hideCaptions(true); + self.languageChooserEl.hide(); + } + }); + + return this.availableTranslationsXHR; + }, + + /** + * @desc Recalculates and updates the height of the container of captions. + * + */ + onResize: function() { + this.subtitlesEl + .find('.spacing').first() + .height(this.topSpacingHeight()); + + this.subtitlesEl + .find('.spacing').last() + .height(this.bottomSpacingHeight()); + + this.scrollCaption(); + this.setSubtitlesHeight(); + }, + + /** + * @desc Create any necessary DOM elements, attach them, and set their + * initial configuration for the Language menu. + * + * @param {object} languages Dictionary where key is language code, + * value - language label + * + */ + renderLanguageMenu: function(languages) { + let self = this, + state = this.state, + $menu = $('