diff --git a/.dockerignore b/.dockerignore index 64eecf5ed739..e8f6e66311c7 100644 --- a/.dockerignore +++ b/.dockerignore @@ -103,6 +103,7 @@ common/test/data/badges/*.png ### Static assets pipeline artifacts **/*.scssc lms/static/css/ +!lms/static/css/vendor lms/static/certificates/css/ cms/static/css/ common/static/common/js/vendor/ diff --git a/.github/workflows/compile-python-requirements.yml b/.github/workflows/compile-python-requirements.yml new file mode 100644 index 000000000000..18e68aa0ad6a --- /dev/null +++ b/.github/workflows/compile-python-requirements.yml @@ -0,0 +1,72 @@ +name: Recompile Python dependencies + +on: + workflow_dispatch: + inputs: + branch: + description: 'Target branch to create requirements PR against' + required: true + default: 'master' + type: string + +defaults: + run: + shell: bash # making this explicit opts into -e -o pipefail + +jobs: + recompile-python-dependencies: + runs-on: ubuntu-20.04 + + steps: + - name: Check out target branch + uses: actions/checkout@v3 + with: + ref: "${{ inputs.branch }}" + + - name: Set up Python environment + uses: actions/setup-python@v4 + with: + python-version: "3.8" + + - name: Run make compile-requirements + env: + PACKAGE: "${{ inputs.package }}" + run: | + make compile-requirements + + - name: PR preflight + run: | + if git diff --exit-code; then + # Fail early (and avoid quiet failure of create-pull-request action) + echo "Error: No changes, so not creating PR." | tee -a "$GITHUB_STEP_SUMMARY" + exit 1 + fi + + - name: Make a PR + id: make-pr + uses: peter-evans/create-pull-request@v5 + with: + branch: "${{ github.triggering_actor }}/compile-python-deps" + branch-suffix: short-commit-hash + add-paths: requirements + commit-message: | + feat: Recompile Python dependencies + + Commit generated by workflow `${{ github.workflow_ref }}` + title: "chore: Recompile Python dependencies" + body: >- + PR generated by workflow `${{ github.workflow_ref }}` + on behalf of @${{ github.triggering_actor }}. + assignees: "${{ github.triggering_actor }}" + reviewers: "${{ github.triggering_actor }}" + + - name: Job summary + env: + PR_URL: "${{ steps.make-pr.outputs.pull-request-url }}" + run: | + if [[ -z "$PR_URL" ]]; then + echo "PR not created; see log for more information" | tee -a "$GITHUB_STEP_SUMMARY" + exit 1 + else + echo "PR created or updated: $PR_URL" | tee -a "$GITHUB_STEP_SUMMARY" + fi diff --git a/.github/workflows/publish-ci-docker-image.yml b/.github/workflows/publish-ci-docker-image.yml index e716c7d15340..997c050b73cd 100644 --- a/.github/workflows/publish-ci-docker-image.yml +++ b/.github/workflows/publish-ci-docker-image.yml @@ -14,7 +14,7 @@ jobs: uses: actions/checkout@v2 - name: Configure AWS Credentials - uses: aws-actions/configure-aws-credentials@v1 + uses: aws-actions/configure-aws-credentials@v3 with: aws-access-key-id: ${{ secrets.TOOLS_EDX_ECR_USER_AWS_ACCESS_KEY_ID }} aws-secret-access-key: ${{ secrets.TOOLS_EDX_ECR_USER_AWS_SECRET_ACCESS_KEY }} diff --git a/.github/workflows/semgrep.yml b/.github/workflows/semgrep.yml new file mode 100644 index 000000000000..f5ff2f63e5c8 --- /dev/null +++ b/.github/workflows/semgrep.yml @@ -0,0 +1,45 @@ +# Finds code problems by structural pattern matching. +# +# New rules can be added to test_root/semgrep/ and they should be picked up +# automatically. See https://semgrep.dev/docs/ for documentation. + +name: Semgrep code quality + +on: + pull_request: + push: + branches: + - master + +jobs: + run_semgrep: + name: Semgrep analysis + runs-on: "${{ matrix.os }}" + strategy: + matrix: + os: [ "ubuntu-20.04" ] + python-version: [ "3.8" ] + + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 1 + + - uses: actions/setup-python@v4 + with: + python-version: "${{ matrix.python-version }}" + + - name: Install semgrep + run: | + make pre-requirements + pip-sync requirements/edx/semgrep.txt + + - name: Run semgrep + env: + # Peg this to some reasonable value so that semgrep's rewrapping + # of messages doesn't break up lines in an unpredictable manner: + # https://github.com/returntocorp/semgrep/issues/8608 + COLUMNS: 80 + run: | + semgrep scan --config test_root/semgrep/ --error --quiet \ + -- lms cms common openedx diff --git a/.github/workflows/upgrade-one-python-dependency.yml b/.github/workflows/upgrade-one-python-dependency.yml index 2ac704e9993e..dbc7eb94487a 100644 --- a/.github/workflows/upgrade-one-python-dependency.yml +++ b/.github/workflows/upgrade-one-python-dependency.yml @@ -2,32 +2,32 @@ name: Upgrade one Python dependency on: workflow_dispatch: - inputs: - branch: - description: 'Target branch to create requirements PR against' - required: true - default: 'master' - type: string - package: - description: 'Name of package to upgrade' - required: true - type: string - version: - description: 'Version number to upgrade to in constraints.txt (only needed if pinned)' - default: '' - type: string - change_desc: - description: | - Description of change, for commit message and PR. (What does the new version add or fix?) - default: '' - type: string + inputs: + branch: + description: 'Target branch to create requirements PR against' + required: true + default: 'master' + type: string + package: + description: 'Name of package to upgrade' + required: true + type: string + version: + description: 'Version number to upgrade to in constraints.txt (only needed if pinned)' + default: '' + type: string + change_desc: + description: | + Description of change, for commit message and PR. (What does the new version add or fix?) + default: '' + type: string defaults: run: shell: bash # making this explicit opts into -e -o pipefail jobs: - upgrade-one-python-dependency-workflow: + upgrade-one-python-dependency: runs-on: ubuntu-20.04 steps: @@ -92,8 +92,9 @@ jobs: ${{ env.body_prefix }}Commit generated by workflow `${{ github.workflow_ref }}` title: "feat: Upgrade Python dependency ${{ inputs.package }}" - body: | - ${{ env.body_prefix }}PR generated by workflow `${{ github.workflow_ref }}` on behalf of @${{ github.triggering_actor }}. + body: >- + ${{ env.body_prefix }}PR generated by workflow `${{ github.workflow_ref }}` + on behalf of @${{ github.triggering_actor }}. assignees: "${{ github.triggering_actor }}" reviewers: "${{ github.triggering_actor }}" diff --git a/.readthedocs.yaml b/.readthedocs.yaml index cf3f80aec8d5..56d794e3567c 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -6,7 +6,7 @@ build: python: "3.8" sphinx: - configuration: source/conf.py + configuration: docs/conf.py python: install: diff --git a/Dockerfile b/Dockerfile index 9e1fa5791045..da215f08dcd7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -91,9 +91,6 @@ RUN apt-get update && \ apt-get -y install --no-install-recommends \ curl \ libssl-dev \ - libxml2-dev \ - libxmlsec1-dev \ - libxslt1-dev \ libffi-dev \ libfreetype6-dev \ libgeos-dev \ diff --git a/Makefile b/Makefile index 32314eef18f0..bc5a79712e29 100644 --- a/Makefile +++ b/Makefile @@ -102,6 +102,7 @@ REQ_FILES = \ requirements/edx/testing \ requirements/edx/development \ requirements/edx/assets \ + requirements/edx/semgrep \ scripts/xblock/requirements define COMMON_CONSTRAINTS_TEMP_COMMENT @@ -111,7 +112,7 @@ endef COMMON_CONSTRAINTS_TXT=requirements/common_constraints.txt .PHONY: $(COMMON_CONSTRAINTS_TXT) $(COMMON_CONSTRAINTS_TXT): - wget -O "$(@)" https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt || touch "$(@)" + wget -O "$(@)" https://raw.githubusercontent.com/edx/edx-lint/master/edx_lint/files/common_constraints.txt printf "$(COMMON_CONSTRAINTS_TEMP_COMMENT)" | cat - $(@) > temp && mv temp $(@) compile-requirements: export CUSTOM_COMPILE_COMMAND=make upgrade diff --git a/README.rst b/README.rst index f8cc99f40d0c..d8d3a880c54b 100644 --- a/README.rst +++ b/README.rst @@ -25,6 +25,11 @@ platform. Functionally, the edx-platform repository provides two services: * CMS (Content Management Service), which powers Open edX Studio, the platform's learning content authoring environment; and * LMS (Learning Management Service), which delivers learning content. +Documentation +************* + +Documentation can be found at https://docs.openedx.org/projects/edx-platform. + Getting Started *************** @@ -81,11 +86,6 @@ and other rich community resources. .. _Open edX site: https://openedx.org -Documentation -************* - -Documentation can be found at https://docs.openedx.org. - Getting Help ************ diff --git a/cms/djangoapps/contentstore/asset_storage_handlers.py b/cms/djangoapps/contentstore/asset_storage_handlers.py index 9cf87e67f824..1c9ba46702b9 100644 --- a/cms/djangoapps/contentstore/asset_storage_handlers.py +++ b/cms/djangoapps/contentstore/asset_storage_handlers.py @@ -88,6 +88,51 @@ def handle_assets(request, course_key_string=None, asset_key_string=None): return HttpResponseNotFound() +def get_asset_usage_path(request, course_key, asset_key_string): + """ + Get a list of units with ancestors that use given asset. + """ + course_key = CourseKey.from_string(course_key) + if not has_course_author_access(request.user, course_key): + raise PermissionDenied() + asset_location = AssetKey.from_string(asset_key_string) if asset_key_string else None + store = modulestore() + usage_locations = [] + static_path = StaticContent.get_static_path_from_location(asset_location) + verticals = store.get_items( + course_key, + qualifiers={ + 'category': 'vertical' + }, + ) + blocks = [] + + for vertical in verticals: + blocks.extend(vertical.get_children()) + + for block in blocks: + is_video_block = getattr(block, 'category', '') == 'video' + if is_video_block: + handout = getattr(block, 'handout', '') + if handout and str(asset_location) in handout: + unit = block.get_parent() + subsection = unit.get_parent() + subsection_display_name = getattr(subsection, 'display_name', '') + unit_display_name = getattr(unit, 'display_name', '') + xblock_display_name = getattr(block, 'display_name', '') + usage_locations.append(f'{subsection_display_name} - {unit_display_name} / {xblock_display_name}') + else: + data = getattr(block, 'data', '') + if static_path in data or str(asset_location) in data: + unit = block.get_parent() + subsection = unit.get_parent() + subsection_display_name = getattr(subsection, 'display_name', '') + unit_display_name = getattr(unit, 'display_name', '') + xblock_display_name = getattr(block, 'display_name', '') + usage_locations.append(f'{subsection_display_name} - {unit_display_name} / {xblock_display_name}') + return JsonResponse({'usage_locations': usage_locations}) + + def _get_response_format(request): return request.GET.get('format') or request.POST.get('format') or 'html' diff --git a/cms/djangoapps/contentstore/helpers.py b/cms/djangoapps/contentstore/helpers.py index c7a39be8f793..c2cc876f19da 100644 --- a/cms/djangoapps/contentstore/helpers.py +++ b/cms/djangoapps/contentstore/helpers.py @@ -147,7 +147,7 @@ def xblock_type_display_name(xblock, default_display_name=None): elif category == 'vertical': return _('Unit') elif category == 'problem': - # The problem XBlock's display_name.default is not helpful ("Blank Advanced Problem") but changing it could have + # The problem XBlock's display_name.default is not helpful ("Blank Problem") but changing it could have # too many ripple effects in other places, so we have a special case for capa problems here. # Note: With a ProblemBlock instance, we could actually check block.problem_types to give a more specific # description like "Multiple Choice Problem", but that won't work if our 'block' argument is just the block_type diff --git a/cms/djangoapps/contentstore/management/commands/tests/test_cleanup_assets.py b/cms/djangoapps/contentstore/management/commands/tests/test_cleanup_assets.py index dfed3bb69caa..e31d566543fe 100644 --- a/cms/djangoapps/contentstore/management/commands/tests/test_cleanup_assets.py +++ b/cms/djangoapps/contentstore/management/commands/tests/test_cleanup_assets.py @@ -4,6 +4,7 @@ """ +from unittest import skip from django.conf import settings from django.core.management import call_command from opaque_keys.edx.keys import CourseKey @@ -20,6 +21,9 @@ TEST_DATA_DIR = settings.COMMON_TEST_DATA_ROOT +@skip("OldMongo Deprecation") +# This test worked only for Old Mongo +# Can later be converted to work with Split class ExportAllCourses(ModuleStoreTestCase): """ Tests assets cleanup for all courses. diff --git a/cms/djangoapps/contentstore/management/commands/tests/test_create_course.py b/cms/djangoapps/contentstore/management/commands/tests/test_create_course.py index 40d1493f94b3..f821c8a6d561 100644 --- a/cms/djangoapps/contentstore/management/commands/tests/test_create_course.py +++ b/cms/djangoapps/contentstore/management/commands/tests/test_create_course.py @@ -4,7 +4,6 @@ from io import StringIO -import ddt from django.core.management import CommandError, call_command from django.test import TestCase @@ -40,27 +39,28 @@ def test_nonexistent_user_email(self): call_command('create_course', "mongo", "fake@example.com", "org", "course", "run") -@ddt.ddt class TestCreateCourse(ModuleStoreTestCase): """ Unit tests for creating a course in either old mongo or split mongo via command line """ - @ddt.data(ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split) - def test_all_stores_user_email(self, store): + def test_all_stores_user_email(self): call_command( "create_course", - store, + ModuleStoreEnum.Type.split, str(self.user.email), "org", "course", "run", "dummy-course-name" ) new_key = modulestore().make_course_key("org", "course", "run") self.assertTrue( modulestore().has_course(new_key), - f"Could not find course in {store}" + f"Could not find course in {ModuleStoreEnum.Type.split}" ) # pylint: disable=protected-access - self.assertEqual(store, modulestore()._get_modulestore_for_courselike(new_key).get_modulestore_type()) + self.assertEqual( + ModuleStoreEnum.Type.split, + modulestore()._get_modulestore_for_courselike(new_key).get_modulestore_type() + ) def test_duplicate_course(self): """ @@ -85,8 +85,7 @@ def test_duplicate_course(self): expected = "Course already exists" self.assertIn(out.getvalue().strip(), expected) - @ddt.data(ModuleStoreEnum.Type.split, ModuleStoreEnum.Type.mongo) - def test_get_course_with_different_case(self, default_store): + def test_get_course_with_different_case(self): """ Tests that course can not be accessed with different case. @@ -98,21 +97,20 @@ def test_get_course_with_different_case(self, default_store): org = 'org1' number = 'course1' run = 'run1' - with self.store.default_store(default_store): - lowercase_course_id = self.store.make_course_key(org, number, run) - with self.store.bulk_operations(lowercase_course_id, ignore_case=True): - # Create course with lowercase key & Verify that store returns course. - self.store.create_course( - lowercase_course_id.org, - lowercase_course_id.course, - lowercase_course_id.run, - self.user.id - ) - course = self.store.get_course(lowercase_course_id) - self.assertIsNotNone(course, 'Course not found using lowercase course key.') - self.assertEqual(str(course.id), str(lowercase_course_id)) - - # Verify store does not return course with different case. - uppercase_course_id = self.store.make_course_key(org.upper(), number.upper(), run.upper()) - course = self.store.get_course(uppercase_course_id) - self.assertIsNone(course, 'Course should not be accessed with uppercase course id.') + lowercase_course_id = self.store.make_course_key(org, number, run) + with self.store.bulk_operations(lowercase_course_id, ignore_case=True): + # Create course with lowercase key & Verify that store returns course. + self.store.create_course( + lowercase_course_id.org, + lowercase_course_id.course, + lowercase_course_id.run, + self.user.id + ) + course = self.store.get_course(lowercase_course_id) + self.assertIsNotNone(course, 'Course not found using lowercase course key.') + self.assertEqual(str(course.id), str(lowercase_course_id)) + + # Verify store does not return course with different case. + uppercase_course_id = self.store.make_course_key(org.upper(), number.upper(), run.upper()) + course = self.store.get_course(uppercase_course_id) + self.assertIsNone(course, 'Course should not be accessed with uppercase course id.') diff --git a/cms/djangoapps/contentstore/management/commands/tests/test_export_all_courses.py b/cms/djangoapps/contentstore/management/commands/tests/test_export_all_courses.py index 262becacbfe5..5c01abde69ab 100644 --- a/cms/djangoapps/contentstore/management/commands/tests/test_export_all_courses.py +++ b/cms/djangoapps/contentstore/management/commands/tests/test_export_all_courses.py @@ -5,6 +5,7 @@ import shutil from tempfile import mkdtemp +from unittest import skip from cms.djangoapps.contentstore.management.commands.export_all_courses import export_courses_to_output_path from xmodule.modulestore import ModuleStoreEnum # lint-amnesty, pylint: disable=wrong-import-order @@ -13,6 +14,10 @@ from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, pylint: disable=wrong-import-order +@skip("OldMongo Deprecation") +# This test fails for split modulestre +# AttributeError: 'MixedModuleStore' object has no attribute 'collection' +# split module store has no 'collection' attribute. class ExportAllCourses(ModuleStoreTestCase): """ Tests exporting all courses. diff --git a/cms/djangoapps/contentstore/management/commands/tests/test_fix_not_found.py b/cms/djangoapps/contentstore/management/commands/tests/test_fix_not_found.py index 8750a4f59e77..b9372d15204b 100644 --- a/cms/djangoapps/contentstore/management/commands/tests/test_fix_not_found.py +++ b/cms/djangoapps/contentstore/management/commands/tests/test_fix_not_found.py @@ -23,14 +23,6 @@ def test_no_args(self): with self.assertRaisesRegex(CommandError, msg): call_command('fix_not_found') - def test_fix_not_found_non_split(self): - """ - The management command doesn't work on non split courses - """ - course = CourseFactory.create(default_store=ModuleStoreEnum.Type.mongo) - with self.assertRaisesRegex(CommandError, "The owning modulestore does not support this command."): - call_command("fix_not_found", str(course.id)) - def test_fix_not_found(self): course = CourseFactory.create(default_store=ModuleStoreEnum.Type.split) BlockFactory.create(category='chapter', parent_location=course.location) diff --git a/cms/djangoapps/contentstore/management/commands/tests/test_force_publish.py b/cms/djangoapps/contentstore/management/commands/tests/test_force_publish.py index 43305ff11f2d..ea16707d7eb8 100644 --- a/cms/djangoapps/contentstore/management/commands/tests/test_force_publish.py +++ b/cms/djangoapps/contentstore/management/commands/tests/test_force_publish.py @@ -58,15 +58,6 @@ def test_course_key_not_found(self): with self.assertRaisesRegex(CommandError, errstring): call_command('force_publish', 'course-v1:org+course+run') - def test_force_publish_non_split(self): - """ - Test 'force_publish' command doesn't work on non split courses - """ - course = CourseFactory.create(default_store=ModuleStoreEnum.Type.mongo) - errstring = 'The owning modulestore does not support this command.' - with self.assertRaisesRegex(CommandError, errstring): - call_command('force_publish', str(course.id)) - class TestForcePublishModifications(ModuleStoreTestCase): """ diff --git a/cms/djangoapps/contentstore/management/commands/tests/test_import.py b/cms/djangoapps/contentstore/management/commands/tests/test_import.py index 3e00def45852..5b90973b0463 100644 --- a/cms/djangoapps/contentstore/management/commands/tests/test_import.py +++ b/cms/djangoapps/contentstore/management/commands/tests/test_import.py @@ -11,7 +11,6 @@ from path import Path as path from openedx.core.djangoapps.django_comment_common.utils import are_permissions_roles_seeded -from xmodule.modulestore import ModuleStoreEnum # lint-amnesty, pylint: disable=wrong-import-order from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order @@ -73,23 +72,3 @@ def test_truncated_course_with_url(self): # Now load up the course with a similar course_id and verify it loads call_command('import', self.content_dir, self.course_dir) self.assertIsNotNone(store.get_course(self.truncated_key)) - - def test_existing_course_with_different_modulestore(self): - """ - Checks that a course that originally existed in old mongo can be re-imported when - split is the default modulestore. - """ - with modulestore().default_store(ModuleStoreEnum.Type.mongo): - call_command('import', self.content_dir, self.good_dir) - - # Clear out the modulestore mappings, else when the next import command goes to create a destination - # course_key, it will find the existing course and return the mongo course_key. To reproduce TNL-1362, - # the destination course_key needs to be the one for split modulestore. - modulestore().mappings = {} - - with modulestore().default_store(ModuleStoreEnum.Type.split): - call_command('import', self.content_dir, self.good_dir) - course = modulestore().get_course(self.base_course_key) - # With the bug, this fails because the chapter's course_key is the split mongo form, - # while the course's course_key is the old mongo form. - self.assertEqual(str(course.location.course_key), str(course.children[0].course_key)) diff --git a/cms/djangoapps/contentstore/rest_api/v1/serializers/__init__.py b/cms/djangoapps/contentstore/rest_api/v1/serializers/__init__.py index 2dc803d47bf2..c60d33bc4448 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/serializers/__init__.py +++ b/cms/djangoapps/contentstore/rest_api/v1/serializers/__init__.py @@ -1,8 +1,10 @@ """ Serializers for v1 contentstore API. """ +from .home import CourseHomeSerializer from .course_details import CourseDetailsSerializer from .course_team import CourseTeamSerializer +from .course_rerun import CourseRerunSerializer from .grading import CourseGradingModelSerializer, CourseGradingSerializer from .proctoring import ( LimitedProctoredExamSettingsSerializer, diff --git a/cms/djangoapps/contentstore/rest_api/v1/serializers/common.py b/cms/djangoapps/contentstore/rest_api/v1/serializers/common.py new file mode 100644 index 000000000000..bc2f8d2da6a2 --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v1/serializers/common.py @@ -0,0 +1,19 @@ +""" +Common API Serializers +""" + +from rest_framework import serializers + +from openedx.core.lib.api.serializers import CourseKeyField + + +class CourseCommonSerializer(serializers.Serializer): + """Serializer for course renders""" + course_key = CourseKeyField() + display_name = serializers.CharField() + lms_link = serializers.CharField() + number = serializers.CharField() + org = serializers.CharField() + rerun_link = serializers.CharField() + run = serializers.CharField() + url = serializers.CharField() diff --git a/cms/djangoapps/contentstore/rest_api/v1/serializers/course_rerun.py b/cms/djangoapps/contentstore/rest_api/v1/serializers/course_rerun.py new file mode 100644 index 000000000000..317468a87a6b --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v1/serializers/course_rerun.py @@ -0,0 +1,15 @@ +""" +API Serializers for course rerun +""" + +from rest_framework import serializers + + +class CourseRerunSerializer(serializers.Serializer): + """ Serializer for course rerun """ + allow_unicode_course_id = serializers.BooleanField() + course_creator_status = serializers.CharField() + display_name = serializers.CharField() + number = serializers.CharField() + org = serializers.CharField() + run = serializers.CharField() diff --git a/cms/djangoapps/contentstore/rest_api/v1/serializers/home.py b/cms/djangoapps/contentstore/rest_api/v1/serializers/home.py new file mode 100644 index 000000000000..5abcda673aa7 --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v1/serializers/home.py @@ -0,0 +1,62 @@ +""" +API Serializers for course home +""" + +from rest_framework import serializers + +from openedx.core.lib.api.serializers import CourseKeyField + +from .common import CourseCommonSerializer + + +class UnsucceededCourseSerializer(serializers.Serializer): + """Serializer for unsucceeded course""" + display_name = serializers.CharField() + course_key = CourseKeyField() + org = serializers.CharField() + number = serializers.CharField() + run = serializers.CharField() + is_failed = serializers.BooleanField() + is_in_progress = serializers.BooleanField() + dismiss_link = serializers.CharField() + + +class LibraryViewSerializer(serializers.Serializer): + """Serializer for library view""" + display_name = serializers.CharField() + library_key = serializers.CharField() + url = serializers.CharField() + org = serializers.CharField() + number = serializers.CharField() + can_edit = serializers.BooleanField() + + +class CourseHomeSerializer(serializers.Serializer): + """Serializer for course home""" + allow_course_reruns = serializers.BooleanField() + allow_to_create_new_org = serializers.BooleanField() + allow_unicode_course_id = serializers.BooleanField() + allowed_organizations = serializers.ListSerializer( + child=serializers.CharField(), + allow_empty=True + ) + archived_courses = CourseCommonSerializer(required=False, many=True) + can_create_organizations = serializers.BooleanField() + course_creator_status = serializers.CharField() + courses = CourseCommonSerializer(required=False, many=True) + in_process_course_actions = UnsucceededCourseSerializer(many=True, required=False, allow_null=True) + libraries = LibraryViewSerializer(many=True, required=False, allow_null=True) + libraries_enabled = serializers.BooleanField() + library_authoring_mfe_url = serializers.CharField() + optimization_enabled = serializers.BooleanField() + redirect_to_library_authoring_mfe = serializers.BooleanField() + request_course_creator_url = serializers.CharField() + rerun_creator_status = serializers.BooleanField() + show_new_library_button = serializers.BooleanField() + split_studio_home = serializers.BooleanField() + studio_name = serializers.CharField() + studio_short_name = serializers.CharField() + studio_request_email = serializers.CharField() + tech_support_email = serializers.CharField() + platform_name = serializers.CharField() + user_is_active = serializers.BooleanField() diff --git a/cms/djangoapps/contentstore/rest_api/v1/serializers/settings.py b/cms/djangoapps/contentstore/rest_api/v1/serializers/settings.py index feec2606205e..742d198a7ad4 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/serializers/settings.py +++ b/cms/djangoapps/contentstore/rest_api/v1/serializers/settings.py @@ -4,23 +4,11 @@ from rest_framework import serializers -from openedx.core.lib.api.serializers import CourseKeyField - - -class PossiblePreRequisiteCourseSerializer(serializers.Serializer): - """ Serializer for possible pre requisite course """ - course_key = CourseKeyField() - display_name = serializers.CharField() - lms_link = serializers.CharField() - number = serializers.CharField() - org = serializers.CharField() - rerun_link = serializers.CharField() - run = serializers.CharField() - url = serializers.CharField() +from .common import CourseCommonSerializer class CourseSettingsSerializer(serializers.Serializer): - """ Serializer for course settings """ + """Serializer for course settings""" about_page_editable = serializers.BooleanField() can_show_certificate_available_date_field = serializers.BooleanField() course_display_name = serializers.CharField() @@ -38,7 +26,7 @@ class CourseSettingsSerializer(serializers.Serializer): marketing_enabled = serializers.BooleanField() mfe_proctored_exam_settings_url = serializers.CharField(required=False, allow_null=True, allow_blank=True) platform_name = serializers.CharField() - possible_pre_requisite_courses = PossiblePreRequisiteCourseSerializer(required=False, many=True) + possible_pre_requisite_courses = CourseCommonSerializer(required=False, many=True) short_description_editable = serializers.BooleanField() show_min_grade_warning = serializers.BooleanField() sidebar_html_enabled = serializers.BooleanField() diff --git a/cms/djangoapps/contentstore/rest_api/v1/urls.py b/cms/djangoapps/contentstore/rest_api/v1/urls.py index 13fec10ddc92..286424f5e1fd 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/urls.py +++ b/cms/djangoapps/contentstore/rest_api/v1/urls.py @@ -1,8 +1,7 @@ """ Contenstore API v1 URLs. """ -from django.urls import path -from django.urls import re_path from django.conf import settings +from django.urls import re_path, path from openedx.core.constants import COURSE_ID_PATTERN @@ -10,7 +9,9 @@ CourseDetailsView, CourseTeamView, CourseGradingView, + CourseRerunView, CourseSettingsView, + HomePageView, ProctoredExamSettingsView, ProctoringErrorsView, xblock, @@ -25,6 +26,11 @@ VIDEO_ID_PATTERN = r'(?:(?P[-\w]+))' urlpatterns = [ + path( + 'home', + HomePageView.as_view(), + name="home" + ), re_path( fr'^proctored_exam_settings/{COURSE_ID_PATTERN}$', ProctoredExamSettingsView.as_view(), @@ -92,4 +98,9 @@ HelpUrlsView.as_view(), name="help_urls" ), + re_path( + fr'^course_rerun/{COURSE_ID_PATTERN}$', + CourseRerunView.as_view(), + name="course_rerun" + ), ] diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/__init__.py b/cms/djangoapps/contentstore/rest_api/v1/views/__init__.py index 044e4653e05d..dc5f1f77c3de 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/__init__.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/__init__.py @@ -3,8 +3,10 @@ """ from .course_details import CourseDetailsView from .course_team import CourseTeamView +from .course_rerun import CourseRerunView from .grading import CourseGradingView from .proctoring import ProctoredExamSettingsView, ProctoringErrorsView +from .home import HomePageView from .settings import CourseSettingsView from .xblock import XblockView from .assets import AssetsView diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/course_rerun.py b/cms/djangoapps/contentstore/rest_api/v1/views/course_rerun.py new file mode 100644 index 000000000000..fe39858c5380 --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v1/views/course_rerun.py @@ -0,0 +1,76 @@ +""" API Views for course rerun """ + +import edx_api_doc_tools as apidocs +from opaque_keys.edx.keys import CourseKey +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.views import APIView + +from cms.djangoapps.contentstore.utils import get_course_rerun_context +from cms.djangoapps.contentstore.rest_api.v1.serializers import CourseRerunSerializer +from common.djangoapps.student.roles import GlobalStaff +from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, verify_course_exists, view_auth_classes +from xmodule.modulestore.django import modulestore + + +@view_auth_classes(is_authenticated=True) +class CourseRerunView(DeveloperErrorViewMixin, APIView): + """ + View for course rerun. + """ + + @apidocs.schema( + parameters=[ + apidocs.string_parameter("course_id", apidocs.ParameterLocation.PATH, description="Course ID"), + ], + responses={ + 200: CourseRerunSerializer, + 401: "The requester is not authenticated.", + 403: "The requester cannot access the specified course.", + 404: "The requested course does not exist.", + }, + ) + @verify_course_exists() + def get(self, request: Request, course_id: str): + """ + Get an object containing course rerun. + + **Example Request** + + GET /api/contentstore/v1/course_rerun/{course_id} + + **Response Values** + + If the request is successful, an HTTP 200 "OK" response is returned. + + The HTTP 200 response contains a single dict that contains keys that + are the course's rerun. + + **Example Response** + + ```json + { + "allow_unicode_course_id": False, + "course_creator_status": "granted", + "number": "101", + "display_name": "new edx course", + "org": "edx", + "run": "2023", + } + ``` + """ + + if not GlobalStaff().has_user(request.user): + self.permission_denied(request) + + course_key = CourseKey.from_string(course_id) + with modulestore().bulk_operations(course_key): + course_block = modulestore().get_course(course_key) + course_rerun_context = get_course_rerun_context(course_key, course_block, request.user) + course_rerun_context.update({ + 'org': course_key.org, + 'number': course_key.course, + 'run': course_key.run, + }) + serializer = CourseRerunSerializer(course_rerun_context) + 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 new file mode 100644 index 000000000000..ea0724e8e2df --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v1/views/home.py @@ -0,0 +1,120 @@ +""" API Views for course home """ + +import edx_api_doc_tools as apidocs +from django.conf import settings +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.views import APIView +from openedx.core.lib.api.view_utils import view_auth_classes + +from ....utils import get_home_context +from ..serializers import CourseHomeSerializer + + +@view_auth_classes(is_authenticated=True) +class HomePageView(APIView): + """ + View for getting all courses and libraries available to the logged in user. + """ + @apidocs.schema( + parameters=[ + apidocs.string_parameter( + "org", + apidocs.ParameterLocation.QUERY, + description="Query param to filter by course org", + )], + responses={ + 200: CourseHomeSerializer, + 401: "The requester is not authenticated.", + }, + ) + def get(self, request: Request): + """ + Get an object containing all courses and libraries on home page. + + **Example Request** + + GET /api/contentstore/v1/home + + **Response Values** + + If the request is successful, an HTTP 200 "OK" response is returned. + + The HTTP 200 response contains a single dict that contains keys that + are the course's home. + + **Example Response** + + ```json + { + "allow_course_reruns": true, + "allow_to_create_new_org": true, + "allow_unicode_course_id": false, + "allowed_organizations": [], + "archived_courses": [ + { + "course_key": "course-v1:edX+P315+2T2023", + "display_name": "Quantum Entanglement", + "lms_link": "//localhost:18000/courses/course-v1:edX+P315+2T2023", + "number": "P315", + "org": "edX", + "rerun_link": "/course_rerun/course-v1:edX+P315+2T2023", + "run": "2T2023" + "url": "/course/course-v1:edX+P315+2T2023" + }, + ], + "can_create_organizations": true, + "course_creator_status": "granted", + "courses": [ + { + "course_key": "course-v1:edX+E2E-101+course", + "display_name": "E2E Test Course", + "lms_link": "//localhost:18000/courses/course-v1:edX+E2E-101+course", + "number": "E2E-101", + "org": "edX", + "rerun_link": "/course_rerun/course-v1:edX+E2E-101+course", + "run": "course", + "url": "/course/course-v1:edX+E2E-101+course" + }, + ], + "in_process_course_actions": [], + "libraries": [ + { + "display_name": "My First Library", + "library_key": "library-v1:new+CPSPR", + "url": "/library/library-v1:new+CPSPR", + "org": "new", + "number": "CPSPR", + "can_edit": true + } + ], + "libraries_enabled": true, + "library_authoring_mfe_url": "//localhost:3001/course/course-v1:edX+P315+2T2023", + "optimization_enabled": true, + "redirect_to_library_authoring_mfe": false, + "request_course_creator_url": "/request_course_creator", + "rerun_creator_status": true, + "show_new_library_button": true, + "split_studio_home": false, + "studio_name": "Studio", + "studio_short_name": "Studio", + "studio_request_email": "", + "tech_support_email": "technical@example.com", + "platform_name": "Your Platform Name Here" + "user_is_active": true, + } + ``` + """ + + home_context = get_home_context(request) + home_context.update({ + 'allow_to_create_new_org': settings.FEATURES.get('ENABLE_CREATOR_GROUP', True) and request.user.is_staff, + 'studio_name': settings.STUDIO_NAME, + 'studio_short_name': settings.STUDIO_SHORT_NAME, + 'studio_request_email': settings.FEATURES.get('STUDIO_REQUEST_EMAIL', ''), + 'tech_support_email': settings.TECH_SUPPORT_EMAIL, + 'platform_name': settings.PLATFORM_NAME, + 'user_is_active': request.user.is_active, + }) + serializer = CourseHomeSerializer(home_context) + return Response(serializer.data) diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_course_rerun.py b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_course_rerun.py new file mode 100644 index 000000000000..e25904ad465f --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_course_rerun.py @@ -0,0 +1,36 @@ +""" +Unit tests for course rerun. +""" +from django.urls import reverse +from rest_framework import status + +from cms.djangoapps.contentstore.tests.utils import CourseTestCase +from cms.djangoapps.contentstore.rest_api.v1.mixins import PermissionAccessMixin + + +class CourseRerunViewTest(CourseTestCase, PermissionAccessMixin): + """ + Tests for CourseRerunView. + """ + + def setUp(self): + super().setUp() + self.url = reverse( + "cms.djangoapps.contentstore:v1:course_rerun", + kwargs={"course_id": self.course.id}, + ) + + def test_course_rerun_response(self): + """Check successful response content""" + response = self.client.get(self.url) + expected_response = { + "allow_unicode_course_id": False, + "course_creator_status": "granted", + "display_name": self.course.display_name, + "number": self.course.id.course, + "org": self.course.id.org, + "run": self.course.id.run, + } + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertDictEqual(expected_response, response.data) 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 new file mode 100644 index 000000000000..110ee24ba150 --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_home.py @@ -0,0 +1,89 @@ +""" +Unit tests for home page view. +""" +import ddt +from django.conf import settings +from django.urls import reverse +from edx_toggles.toggles.testutils import override_waffle_switch +from rest_framework import status + +from cms.djangoapps.contentstore.tests.utils import CourseTestCase +from cms.djangoapps.contentstore.views.course import ENABLE_GLOBAL_STAFF_OPTIMIZATION +from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory +from xmodule.modulestore.tests.factories import CourseFactory + + +@ddt.ddt +class HomePageViewTest(CourseTestCase): + """ + Tests for HomePageView. + """ + + def setUp(self): + super().setUp() + self.url = reverse("cms.djangoapps.contentstore:v1:home") + + def test_home_page_response(self): + """Check successful response content""" + response = self.client.get(self.url) + course_id = str(self.course.id) + + expected_response = { + "allow_course_reruns": True, + "allow_to_create_new_org": False, + "allow_unicode_course_id": False, + "allowed_organizations": [], + "archived_courses": [], + "can_create_organizations": True, + "course_creator_status": "granted", + "courses": [{ + "course_key": course_id, + "display_name": self.course.display_name, + "lms_link": f'//{settings.LMS_BASE}/courses/{course_id}/jump_to/{self.course.location}', + "number": self.course.number, + "org": self.course.org, + "rerun_link": f'/course_rerun/{course_id}', + "run": self.course.id.run, + "url": f'/course/{course_id}', + }], + "in_process_course_actions": [], + "libraries": [], + "libraries_enabled": True, + "library_authoring_mfe_url": settings.LIBRARY_AUTHORING_MICROFRONTEND_URL, + "optimization_enabled": False, + "redirect_to_library_authoring_mfe": False, + "request_course_creator_url": "/request_course_creator", + "rerun_creator_status": True, + "show_new_library_button": True, + "split_studio_home": False, + "studio_name": settings.STUDIO_NAME, + "studio_short_name": settings.STUDIO_SHORT_NAME, + "studio_request_email": "", + "tech_support_email": "technical@example.com", + "platform_name": settings.PLATFORM_NAME, + "user_is_active": True, + } + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertDictEqual(expected_response, response.data) + + @override_waffle_switch(ENABLE_GLOBAL_STAFF_OPTIMIZATION, True) + def test_org_query_if_passed(self): + """Test home page when org filter passed as a query param""" + foo_course = self.store.make_course_key('foo-org', 'bar-number', 'baz-run') + test_course = CourseFactory.create( + org=foo_course.org, + number=foo_course.course, + run=foo_course.run + ) + CourseOverviewFactory.create(id=test_course.id, org='foo-org') + response = self.client.get(self.url, {"org": "foo-org"}) + self.assertEqual(len(response.data['courses']), 1) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + @override_waffle_switch(ENABLE_GLOBAL_STAFF_OPTIMIZATION, True) + def test_org_query_if_empty(self): + """Test home page with an empty org query param""" + response = self.client.get(self.url) + self.assertEqual(len(response.data['courses']), 0) + self.assertEqual(response.status_code, status.HTTP_200_OK) diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index 9162598645b9..1eb70347399c 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -1406,11 +1406,16 @@ def test_course_overview_view_with_course(self): 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 + '
'.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 diff --git a/cms/djangoapps/contentstore/tests/test_import_pure_xblock.py b/cms/djangoapps/contentstore/tests/test_import_pure_xblock.py index a4a4368bdf59..0a0e8663bae3 100644 --- a/cms/djangoapps/contentstore/tests/test_import_pure_xblock.py +++ b/cms/djangoapps/contentstore/tests/test_import_pure_xblock.py @@ -7,9 +7,6 @@ from xblock.core import XBlock from xblock.fields import String -from xmodule.modulestore import ModuleStoreEnum -from xmodule.modulestore.django import modulestore -from xmodule.modulestore.mongo.draft import as_draft from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.xml_importer import import_course_from_xml @@ -41,14 +38,6 @@ def test_import_public(self): 'set by xml' ) - @XBlock.register_temp_plugin(StubXBlock) - def test_import_draft(self): - self._assert_import( - 'pure_xblock_draft', - 'set by xml', - has_draft=True - ) - def _assert_import(self, course_dir, expected_field_val, has_draft=False): """ Import a course from XML, then verify that the XBlock was loaded @@ -66,22 +55,12 @@ def _assert_import(self, course_dir, expected_field_val, has_draft=False): """ # It is necessary to use the "old mongo" modulestore because split doesn't work # with the "has_draft" logic below. - store = modulestore()._get_modulestore_by_type(ModuleStoreEnum.Type.mongo) # pylint: disable=protected-access courses = import_course_from_xml( - store, self.user.id, TEST_DATA_DIR, [course_dir], create_if_not_present=True + self.store, self.user.id, TEST_DATA_DIR, [course_dir], create_if_not_present=True ) xblock_location = courses[0].id.make_usage_key('stubxblock', 'xblock_test') - if has_draft: - xblock_location = as_draft(xblock_location) - - xblock = store.get_item(xblock_location) + xblock = self.store.get_item(xblock_location) self.assertTrue(isinstance(xblock, StubXBlock)) self.assertEqual(xblock.test_field, expected_field_val) - - if has_draft: - draft_xblock = store.get_item(xblock_location) - self.assertTrue(getattr(draft_xblock, 'is_draft', False)) - self.assertTrue(isinstance(draft_xblock, StubXBlock)) - self.assertEqual(draft_xblock.test_field, expected_field_val) diff --git a/cms/djangoapps/contentstore/tests/test_libraries.py b/cms/djangoapps/contentstore/tests/test_libraries.py index eb9b109cdd35..bb692c016033 100644 --- a/cms/djangoapps/contentstore/tests/test_libraries.py +++ b/cms/djangoapps/contentstore/tests/test_libraries.py @@ -1014,28 +1014,3 @@ def test_duplicated_version(self): self.assertEqual(self.lc_block.source_library_version, duplicate.source_library_version) problem2_in_course = store.get_item(duplicate.children[0]) self.assertEqual(problem2_in_course.display_name, self.original_display_name) - - -class TestIncompatibleModuleStore(LibraryTestCase): - """ - Tests for proper validation errors with an incompatible course modulestore. - """ - - def setUp(self): - super().setUp() - # Create a course in an incompatible modulestore. - with modulestore().default_store(ModuleStoreEnum.Type.mongo): - self.course = CourseFactory.create() - - # Add a LibraryContent block to the course: - self.lc_block = self._add_library_content_block(self.course, self.lib_key) - - def test_incompatible_modulestore(self): - """ - Verifies that, if a user is using a modulestore that doesn't support libraries, - a validation error will be produced. - """ - validation = self.lc_block.validate() - self.assertEqual(validation.summary.type, validation.summary.ERROR) - self.assertIn( - "This course does not support content libraries.", validation.summary.text) diff --git a/cms/djangoapps/contentstore/tests/utils.py b/cms/djangoapps/contentstore/tests/utils.py index 0536db140587..bb1ca0205234 100644 --- a/cms/djangoapps/contentstore/tests/utils.py +++ b/cms/djangoapps/contentstore/tests/utils.py @@ -12,7 +12,7 @@ from xmodule.contentstore.django import contentstore from xmodule.modulestore.inheritance import own_metadata from xmodule.modulestore.split_mongo.split import SplitMongoModuleStore -from xmodule.modulestore.tests.django_utils import TEST_DATA_MONGO_MODULESTORE, ModuleStoreTestCase +from xmodule.modulestore.tests.django_utils import TEST_DATA_SPLIT_MODULESTORE, ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory from xmodule.modulestore.tests.utils import ProceduralCourseTestMixin from xmodule.tests.test_transcripts_utils import YoutubeVideoHTMLResponse @@ -73,7 +73,7 @@ class CourseTestCase(ProceduralCourseTestMixin, ModuleStoreTestCase): Base class for Studio tests that require a logged in user and a course. Also provides helper methods for manipulating and verifying the course. """ - MODULESTORE = TEST_DATA_MONGO_MODULESTORE + MODULESTORE = TEST_DATA_SPLIT_MODULESTORE def setUp(self): """ @@ -123,7 +123,7 @@ def save_course(self): SEQUENTIAL = 'vertical_sequential' DRAFT_HTML = 'draft_html' DRAFT_VIDEO = 'draft_video' - LOCKED_ASSET_KEY = AssetKey.from_string('/c4x/edX/toy/asset/sample_static.html') + LOCKED_ASSET_KEY = AssetKey.from_string('asset-v1:edX+toy+2012_Fall+type@asset+block@sample_static.html') def assertCoursesEqual(self, course1_id, course2_id): """ diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py index 321b97be6f78..97b70e828ce2 100644 --- a/cms/djangoapps/contentstore/utils.py +++ b/cms/djangoapps/contentstore/utils.py @@ -24,6 +24,7 @@ from xblock.fields import Scope from cms.djangoapps.contentstore.toggles import exam_setting_view_enabled +from common.djangoapps.course_action_state.models import CourseRerunUIStateManager from common.djangoapps.course_modes.models import CourseMode from common.djangoapps.edxmako.services import MakoService from common.djangoapps.student import auth @@ -59,6 +60,7 @@ from openedx.features.content_type_gating.partitions import CONTENT_TYPE_GATING_SCHEME from openedx.features.course_experience.waffle import ENABLE_COURSE_ABOUT_SIDEBAR_HTML from cms.djangoapps.contentstore.toggles import ( + split_library_view_on_dashboard, use_new_advanced_settings_page, use_new_course_outline_page, use_new_export_page, @@ -68,12 +70,13 @@ use_new_home_page, use_new_import_page, use_new_schedule_details_page, + use_new_text_editor, use_new_unit_page, use_new_updates_page, + use_new_video_editor, use_new_video_uploads_page, use_new_custom_pages, ) -from cms.djangoapps.contentstore.toggles import use_new_text_editor, use_new_video_editor from cms.djangoapps.models.settings.course_grading import CourseGradingModel from xmodule.library_tools import LibraryToolsService from xmodule.modulestore import ModuleStoreEnum # lint-amnesty, pylint: disable=wrong-import-order @@ -1387,6 +1390,109 @@ def get_help_urls(): return help_tokens +def get_home_context(request): + """ + Utils is used to get context of course grading. + It is used for both DRF and django views. + """ + + from cms.djangoapps.contentstore.views.course import ( + get_allowed_organizations, + get_allowed_organizations_for_libraries, + get_courses_accessible_to_user, + user_can_create_organizations, + _accessible_libraries_iter, + _get_course_creator_status, + _format_library_for_view, + _process_courses_list, + ENABLE_GLOBAL_STAFF_OPTIMIZATION, + ) + from cms.djangoapps.contentstore.views.library import ( + LIBRARY_AUTHORING_MICROFRONTEND_URL, + LIBRARIES_ENABLED, + should_redirect_to_library_authoring_mfe, + user_can_create_library, + ) + + optimization_enabled = GlobalStaff().has_user(request.user) and ENABLE_GLOBAL_STAFF_OPTIMIZATION.is_enabled() + + org = request.GET.get('org', '') if optimization_enabled else None + courses_iter, in_process_course_actions = get_courses_accessible_to_user(request, org) + user = request.user + libraries = [] + if not split_library_view_on_dashboard() and LIBRARIES_ENABLED: + libraries = _accessible_libraries_iter(request.user) + + def format_in_process_course_view(uca): + """ + Return a dict of the data which the view requires for each unsucceeded course + """ + return { + 'display_name': uca.display_name, + 'course_key': str(uca.course_key), + 'org': uca.course_key.org, + 'number': uca.course_key.course, + 'run': uca.course_key.run, + 'is_failed': uca.state == CourseRerunUIStateManager.State.FAILED, + 'is_in_progress': uca.state == CourseRerunUIStateManager.State.IN_PROGRESS, + 'dismiss_link': reverse_course_url( + 'course_notifications_handler', + uca.course_key, + kwargs={ + 'action_state_id': uca.id, + }, + ) if uca.state == CourseRerunUIStateManager.State.FAILED else '' + } + + split_archived = settings.FEATURES.get('ENABLE_SEPARATE_ARCHIVED_COURSES', False) + active_courses, archived_courses = _process_courses_list(courses_iter, in_process_course_actions, split_archived) + in_process_course_actions = [format_in_process_course_view(uca) for uca in in_process_course_actions] + + home_context = { + 'courses': active_courses, + 'split_studio_home': split_library_view_on_dashboard(), + 'archived_courses': archived_courses, + 'in_process_course_actions': in_process_course_actions, + 'libraries_enabled': LIBRARIES_ENABLED, + 'redirect_to_library_authoring_mfe': should_redirect_to_library_authoring_mfe(), + 'library_authoring_mfe_url': LIBRARY_AUTHORING_MICROFRONTEND_URL, + 'libraries': [_format_library_for_view(lib, request) for lib in libraries], + 'show_new_library_button': user_can_create_library(user) and not should_redirect_to_library_authoring_mfe(), + 'user': user, + 'request_course_creator_url': reverse('request_course_creator'), + 'course_creator_status': _get_course_creator_status(user), + 'rerun_creator_status': GlobalStaff().has_user(user), + 'allow_unicode_course_id': settings.FEATURES.get('ALLOW_UNICODE_COURSE_ID', False), + 'allow_course_reruns': settings.FEATURES.get('ALLOW_COURSE_RERUNS', True), + 'optimization_enabled': optimization_enabled, + 'active_tab': 'courses', + 'allowed_organizations': get_allowed_organizations(user), + 'allowed_organizations_for_libraries': get_allowed_organizations_for_libraries(user), + 'can_create_organizations': user_can_create_organizations(user), + } + + return home_context + + +def get_course_rerun_context(course_key, course_block, user): + """ + Utils is used to get context of course rerun. + It is used for both DRF and django views. + """ + + from cms.djangoapps.contentstore.views.course import _get_course_creator_status + + course_rerun_context = { + 'source_course_key': course_key, + 'display_name': course_block.display_name, + 'user': user, + 'course_creator_status': _get_course_creator_status(user), + 'allow_unicode_course_id': settings.FEATURES.get('ALLOW_UNICODE_COURSE_ID', False) + } + + return course_rerun_context + + class StudioPermissionsService: """ Service that can provide information about a user's permissions. diff --git a/cms/djangoapps/contentstore/views/assets.py b/cms/djangoapps/contentstore/views/assets.py index af8a2242e8de..cca58e8c6b03 100644 --- a/cms/djangoapps/contentstore/views/assets.py +++ b/cms/djangoapps/contentstore/views/assets.py @@ -5,14 +5,16 @@ from django.views.decorators.csrf import ensure_csrf_cookie from cms.djangoapps.contentstore.asset_storage_handlers import ( handle_assets, + get_asset_usage_path, update_course_run_asset as update_course_run_asset_source_function, get_file_size as get_file_size_source_function, delete_asset as delete_asset_source_function, get_asset_json as get_asset_json_source_function, update_asset as update_asset_source_function, + ) -__all__ = ['assets_handler'] +__all__ = ['assets_handler', 'asset_usage_path_handler'] REQUEST_DEFAULTS = { 'page': 0, @@ -52,6 +54,12 @@ def assets_handler(request, course_key_string=None, asset_key_string=None): return handle_assets(request, course_key_string, asset_key_string) +@login_required +@ensure_csrf_cookie +def asset_usage_path_handler(request, course_key_string, asset_key_string): + return get_asset_usage_path(request, course_key_string, asset_key_string) + + def update_course_run_asset(course_key, upload_file): """Exposes service method in asset_storage_handlers without breaking existing bindings/dependencies""" return update_course_run_asset_source_function(course_key, upload_file) diff --git a/cms/djangoapps/contentstore/views/component.py b/cms/djangoapps/contentstore/views/component.py index 148b259898cc..3c31637d4c78 100644 --- a/cms/djangoapps/contentstore/views/component.py +++ b/cms/djangoapps/contentstore/views/component.py @@ -336,7 +336,7 @@ def create_support_legend_dict(): template_id = "peer-assessment" elif category == 'problem': # Override generic "Problem" name to describe this blank template: - display_name = _("Blank Advanced Problem") + display_name = _("Blank Problem") templates_for_category.append( create_template_dict(display_name, category, support_level_without_template, template_id, 'advanced') ) diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index 28de4cfde9bb..d74a99a8e391 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -31,6 +31,7 @@ from organizations.exceptions import InvalidOrganizationException from rest_framework.exceptions import ValidationError +from cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers import create_xblock_info from cms.djangoapps.course_creators.views import add_user_with_status_unrequested, get_course_creator_status from cms.djangoapps.course_creators.models import CourseCreator from cms.djangoapps.models.settings.course_grading import CourseGradingModel @@ -100,6 +101,7 @@ add_instructor, get_course_settings, get_course_grading, + get_home_context, get_lms_link_for_item, get_proctored_exam_settings_url, get_course_outline_url, @@ -108,6 +110,7 @@ get_advanced_settings_url, get_grading_url, get_schedule_details_url, + get_course_rerun_context, initialize_permissions, remove_all_instructors, reverse_course_url, @@ -118,15 +121,7 @@ update_course_discussions_settings, ) from .component import ADVANCED_COMPONENT_TYPES -from cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers import ( - create_xblock_info, -) -from .library import ( - LIBRARIES_ENABLED, - LIBRARY_AUTHORING_MICROFRONTEND_URL, - user_can_create_library, - should_redirect_to_library_authoring_mfe -) +from .library import LIBRARIES_ENABLED log = logging.getLogger(__name__) User = get_user_model() @@ -334,13 +329,8 @@ def course_rerun_handler(request, course_key_string): with modulestore().bulk_operations(course_key): course_block = get_course_and_check_access(course_key, request.user, depth=3) if request.method == 'GET': - return render_to_response('course-create-rerun.html', { - 'source_course_key': course_key, - 'display_name': course_block.display_name, - 'user': request.user, - 'course_creator_status': _get_course_creator_status(request.user), - 'allow_unicode_course_id': settings.FEATURES.get('ALLOW_UNICODE_COURSE_ID', False) - }) + course_rerun_context = get_course_rerun_context(course_key, course_block, request.user) + return render_to_response('course-create-rerun.html', course_rerun_context) @login_required @@ -551,62 +541,8 @@ def course_listing(request): if use_new_home_page(): return redirect(get_studio_home_url()) - optimization_enabled = GlobalStaff().has_user(request.user) and ENABLE_GLOBAL_STAFF_OPTIMIZATION.is_enabled() - - org = request.GET.get('org', '') if optimization_enabled else None - courses_iter, in_process_course_actions = get_courses_accessible_to_user(request, org) - user = request.user - libraries = [] - if not split_library_view_on_dashboard() and LIBRARIES_ENABLED: - libraries = _accessible_libraries_iter(request.user) - - def format_in_process_course_view(uca): - """ - Return a dict of the data which the view requires for each unsucceeded course - """ - return { - 'display_name': uca.display_name, - 'course_key': str(uca.course_key), - 'org': uca.course_key.org, - 'number': uca.course_key.course, - 'run': uca.course_key.run, - 'is_failed': uca.state == CourseRerunUIStateManager.State.FAILED, - 'is_in_progress': uca.state == CourseRerunUIStateManager.State.IN_PROGRESS, - 'dismiss_link': reverse_course_url( - 'course_notifications_handler', - uca.course_key, - kwargs={ - 'action_state_id': uca.id, - }, - ) if uca.state == CourseRerunUIStateManager.State.FAILED else '' - } - - split_archived = settings.FEATURES.get('ENABLE_SEPARATE_ARCHIVED_COURSES', False) - active_courses, archived_courses = _process_courses_list(courses_iter, in_process_course_actions, split_archived) - in_process_course_actions = [format_in_process_course_view(uca) for uca in in_process_course_actions] - - return render_to_response('index.html', { - 'courses': active_courses, - 'split_studio_home': split_library_view_on_dashboard(), - 'archived_courses': archived_courses, - 'in_process_course_actions': in_process_course_actions, - 'libraries_enabled': LIBRARIES_ENABLED, - 'redirect_to_library_authoring_mfe': should_redirect_to_library_authoring_mfe(), - 'library_authoring_mfe_url': LIBRARY_AUTHORING_MICROFRONTEND_URL, - 'libraries': [_format_library_for_view(lib, request) for lib in libraries], - 'show_new_library_button': user_can_create_library(user) and not should_redirect_to_library_authoring_mfe(), - 'user': user, - 'request_course_creator_url': reverse('request_course_creator'), - 'course_creator_status': _get_course_creator_status(user), - 'rerun_creator_status': GlobalStaff().has_user(user), - 'allow_unicode_course_id': settings.FEATURES.get('ALLOW_UNICODE_COURSE_ID', False), - 'allow_course_reruns': settings.FEATURES.get('ALLOW_COURSE_RERUNS', True), - 'optimization_enabled': optimization_enabled, - 'active_tab': 'courses', - 'allowed_organizations': get_allowed_organizations(user), - 'allowed_organizations_for_libraries': get_allowed_organizations_for_libraries(user), - 'can_create_organizations': user_can_create_organizations(user), - }) + home_context = get_home_context(request) + return render_to_response('index.html', home_context) @login_required diff --git a/cms/djangoapps/contentstore/views/tests/test_block.py b/cms/djangoapps/contentstore/views/tests/test_block.py index a25d0670b4be..26b3f91a0bd7 100644 --- a/cms/djangoapps/contentstore/views/tests/test_block.py +++ b/cms/djangoapps/contentstore/views/tests/test_block.py @@ -111,13 +111,11 @@ def setUp(self): self.course_key = self.course.id self.usage_key = self.course.location - def get_item_from_modulestore(self, usage_key, verify_is_draft=False): + def get_item_from_modulestore(self, usage_key): """ Get the item referenced by the UsageKey from the modulestore """ item = self.store.get_item(usage_key) - if verify_is_draft: - self.assertTrue(getattr(item, "is_draft", False)) return item def response_usage_key(self, response): @@ -540,9 +538,8 @@ def assert_xblock_info(xblock, xblock_info): class DeleteItem(ItemTest): """Tests for '/xblock' DELETE url.""" - @ddt.data(ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split) - def test_delete_static_page(self, store): - course = CourseFactory.create(default_store=store) + def test_delete_static_page(self): + course = CourseFactory.create() # Add static tab resp = self.create_xblock( category="static_tab", parent_usage_key=course.location @@ -589,7 +586,7 @@ def test_create_nicely(self): parent_usage_key=vert_usage_key, category="problem", boilerplate=template_id ) prob_usage_key = self.response_usage_key(resp) - problem = self.get_item_from_modulestore(prob_usage_key, verify_is_draft=True) + problem = self.get_item_from_modulestore(prob_usage_key) # check against the template template = ProblemBlock.get_template(template_id) self.assertEqual(problem.data, template["data"]) @@ -807,9 +804,7 @@ def setUp(self): self.html_usage_key = self.response_usage_key(resp) # Create a second sequential just (testing children of children) - self.create_xblock( - parent_usage_key=self.chapter_usage_key, category="sequential2" - ) + self.create_xblock(parent_usage_key=self.chapter_usage_key, category='sequential') def test_duplicate_equality(self): """ @@ -976,10 +971,10 @@ def setup_course(self, default_store=None): if not default_store: default_store = self.store.default_modulestore.get_modulestore_type() - self.course = CourseFactory.create(default_store=default_store) + course = CourseFactory.create(default_store=default_store) # Create group configurations - self.course.user_partitions = [ + course.user_partitions = [ UserPartition( 0, "first_partition", @@ -987,18 +982,18 @@ def setup_course(self, default_store=None): [Group("0", "alpha"), Group("1", "beta")], ) ] - self.store.update_item(self.course, self.user.id) + self.store.update_item(course, self.user.id) # Create a parent chapter chap1 = self.create_xblock( - parent_usage_key=self.course.location, + parent_usage_key=course.location, display_name="chapter1", category="chapter", ) self.chapter_usage_key = self.response_usage_key(chap1) chap2 = self.create_xblock( - parent_usage_key=self.course.location, + parent_usage_key=course.location, display_name="chapter2", category="chapter", ) @@ -1053,6 +1048,8 @@ def setup_course(self, default_store=None): ) self.split_test_usage_key = self.response_usage_key(resp) + self.course = self.store.get_item(course.location) + def setup_and_verify_content_experiment(self, partition_id): """ Helper method to set up group configurations to content experiment. @@ -1060,9 +1057,7 @@ def setup_and_verify_content_experiment(self, partition_id): Arguments: partition_id (int): User partition id. """ - split_test = self.get_item_from_modulestore( - self.split_test_usage_key, verify_is_draft=True - ) + split_test = self.get_item_from_modulestore(self.split_test_usage_key) # Initially, no user_partition_id is set, and the split_test has no children. self.assertEqual(split_test.user_partition_id, -1) @@ -1073,9 +1068,7 @@ def setup_and_verify_content_experiment(self, partition_id): reverse_usage_url("xblock_handler", self.split_test_usage_key), data={"metadata": {"user_partition_id": str(partition_id)}}, ) - split_test = self.get_item_from_modulestore( - self.split_test_usage_key, verify_is_draft=True - ) + split_test = self.get_item_from_modulestore(self.split_test_usage_key) self.assertEqual(split_test.user_partition_id, partition_id) self.assertEqual( len(split_test.children), @@ -1141,15 +1134,11 @@ def assert_move_item(self, source_usage_key, target_usage_key, target_index=None self.assertIn(source_usage_key, target_parent.children) self.assertNotIn(source_usage_key, source_parent.children) - @ddt.data(ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split) - def test_move_component(self, store_type): + def test_move_component(self): """ Test move component with different xblock types. - - Arguments: - store_type (ModuleStoreEnum.Type): Type of modulestore to create test course in. """ - self.setup_course(default_store=store_type) + self.setup_course() for source_usage_key, target_usage_key in [ (self.html_usage_key, self.vert2_usage_key), (self.vert_usage_key, self.seq2_usage_key), @@ -1391,9 +1380,7 @@ def test_can_not_move_content_experiment_into_its_children(self): reverse_usage_url("xblock_handler", child_split_test_usage_key), data={"metadata": {"user_partition_id": str(0)}}, ) - child_split_test = self.get_item_from_modulestore( - self.split_test_usage_key, verify_is_draft=True - ) + child_split_test = self.get_item_from_modulestore(self.split_test_usage_key) # Try to move content experiment further down the level to a child group A nested inside main group A. response = self._move_component( @@ -1469,6 +1456,7 @@ def test_move_component_nonsensical_access_restriction_validation(self): """ group1 = self.course.user_partitions[0].groups[0] group2 = self.course.user_partitions[0].groups[1] + vert1 = self.store.get_item(self.vert_usage_key) vert2 = self.store.get_item(self.vert2_usage_key) html = self.store.get_item(self.html_usage_key) @@ -1481,10 +1469,12 @@ def test_move_component_nonsensical_access_restriction_validation(self): html.runtime._services["partitions"] = partitions_service # lint-amnesty, pylint: disable=protected-access # Set access settings so html will contradict vert2 when moved into that unit + vert1.group_access = {self.course.user_partitions[0].id: [group2.id]} vert2.group_access = {self.course.user_partitions[0].id: [group1.id]} html.group_access = {self.course.user_partitions[0].id: [group2.id]} - self.store.update_item(html, self.user.id) - self.store.update_item(vert2, self.user.id) + vert1 = self.store.update_item(vert1, self.user.id) + vert2 = self.store.update_item(vert2, self.user.id) + html = self.store.update_item(html, self.user.id) # Verify that there is no warning when html is in a non contradicting unit validation = html.validate() @@ -1493,7 +1483,7 @@ def test_move_component_nonsensical_access_restriction_validation(self): # Now move it and confirm that the html component has been moved into vertical 2 self.assert_move_item(self.html_usage_key, self.vert2_usage_key) html.parent = self.vert2_usage_key - self.store.update_item(html, self.user.id) + html = self.store.update_item(html, self.user.id) validation = html.validate() self.assertEqual(len(validation.messages), 1) self._verify_validation_message( @@ -1505,7 +1495,7 @@ def test_move_component_nonsensical_access_restriction_validation(self): # Move the html component back and confirm that the warning is gone again self.assert_move_item(self.html_usage_key, self.vert_usage_key) html.parent = self.vert_usage_key - self.store.update_item(html, self.user.id) + html = self.store.update_item(html, self.user.id) validation = html.validate() self.assertEqual(len(validation.messages), 0) @@ -1527,16 +1517,12 @@ def test_move_logging(self, mock_logger): insert_at, ) - @ddt.data(ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split) - def test_move_and_discard_changes(self, store_type): + def test_move_and_discard_changes(self): """ Verifies that discard changes operation brings moved component back to source location and removes the component from target location. - - Arguments: - store_type (ModuleStoreEnum.Type): Type of modulestore to create test course in. """ - self.setup_course(default_store=store_type) + self.setup_course() old_parent_loc = self.store.get_parent_location(self.html_usage_key) @@ -1594,15 +1580,11 @@ def test_move_and_discard_changes(self, store_type): self.assertIn(self.html_usage_key, source_parent.children) self.assertNotIn(self.html_usage_key, target_parent.children) - @ddt.data(ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split) - def test_move_item_not_found(self, store_type=ModuleStoreEnum.Type.mongo): + def test_move_item_not_found(self): """ Test that an item not found exception raised when an item is not found when getting the item. - - Arguments: - store_type (ModuleStoreEnum.Type): Type of modulestore to create test course in. """ - self.setup_course(default_store=store_type) + self.setup_course() data = { "move_source_locator": str( @@ -1752,30 +1734,25 @@ def test_delete_field(self): self.client.ajax_post( self.problem_update_url, data={"metadata": {"rerandomize": "onreset"}} ) - problem = self.get_item_from_modulestore( - self.problem_usage_key, verify_is_draft=True - ) - self.assertEqual(problem.rerandomize, "onreset") + problem = self.get_item_from_modulestore(self.problem_usage_key) + self.assertEqual(problem.rerandomize, 'onreset') self.client.ajax_post( self.problem_update_url, data={"metadata": {"rerandomize": None}} ) - problem = self.get_item_from_modulestore( - self.problem_usage_key, verify_is_draft=True - ) - self.assertEqual(problem.rerandomize, "never") + problem = self.get_item_from_modulestore(self.problem_usage_key) + self.assertEqual(problem.rerandomize, 'never') def test_null_field(self): """ Sending null in for a field 'deletes' it """ - problem = self.get_item_from_modulestore( - self.problem_usage_key, verify_is_draft=True - ) + problem = self.get_item_from_modulestore(self.problem_usage_key) self.assertIsNotNone(problem.markdown) - self.client.ajax_post(self.problem_update_url, data={"nullout": ["markdown"]}) - problem = self.get_item_from_modulestore( - self.problem_usage_key, verify_is_draft=True + self.client.ajax_post( + self.problem_update_url, + data={'nullout': ['markdown']} ) + problem = self.get_item_from_modulestore(self.problem_usage_key) self.assertIsNone(problem.markdown) def test_date_fields(self): @@ -1831,9 +1808,7 @@ def test_update_generic_fields(self): } }, ) - problem = self.get_item_from_modulestore( - self.problem_usage_key, verify_is_draft=True - ) + problem = self.get_item_from_modulestore(self.problem_usage_key) self.assertEqual(problem.display_name, new_display_name) self.assertEqual(problem.max_attempts, new_max_attempts) @@ -2052,9 +2027,7 @@ def test_republish(self): }, ) self.assertFalse(self._is_location_published(self.problem_usage_key)) - draft = self.get_item_from_modulestore( - self.problem_usage_key, verify_is_draft=True - ) + draft = self.get_item_from_modulestore(self.problem_usage_key) self.assertEqual(draft.display_name, new_display_name) # Publish the item @@ -2112,9 +2085,7 @@ def _make_draft_content_different_from_published(self): self.client.ajax_post( self.problem_update_url, data={"metadata": {"due": "2077-10-10T04:00Z"}} ) - updated_draft = self.get_item_from_modulestore( - self.problem_usage_key, verify_is_draft=True - ) + updated_draft = self.get_item_from_modulestore(self.problem_usage_key) self.assertEqual(updated_draft.due, datetime(2077, 10, 10, 4, 0, tzinfo=UTC)) self.assertIsNone(published.due) # Fetch the published version again to make sure the due date is still unset. @@ -2154,9 +2125,7 @@ def test_published_and_draft_contents_with_update(self): ) # Both published and draft content should be different - draft = self.get_item_from_modulestore( - self.problem_usage_key, verify_is_draft=True - ) + draft = self.get_item_from_modulestore(self.problem_usage_key) self.assertNotEqual(draft.data, published.data) # Get problem by 'xblock_handler' @@ -2174,9 +2143,7 @@ def test_published_and_draft_contents_with_update(self): self.assertEqual(resp.status_code, 200) # Both published and draft content should still be different - draft = self.get_item_from_modulestore( - self.problem_usage_key, verify_is_draft=True - ) + draft = self.get_item_from_modulestore(self.problem_usage_key) self.assertNotEqual(draft.data, published.data) # Fetch the published version again to make sure the data is correct. published = modulestore().get_item( @@ -2209,18 +2176,6 @@ def test_publish_states_of_nested_xblocks(self): self._verify_published_with_no_draft(unit_usage_key) self._verify_published_with_no_draft(html_usage_key) - # Make a draft for the unit and verify that the problem also has a draft - resp = self.client.ajax_post( - unit_update_url, - data={ - "id": str(unit_usage_key), - "metadata": {}, - }, - ) - self.assertEqual(resp.status_code, 200) - self._verify_published_with_draft(unit_usage_key) - self._verify_published_with_draft(html_usage_key) - def test_field_value_errors(self): """ Test that if the user's input causes a ValueError on an XBlock field, @@ -2346,9 +2301,7 @@ def _update_partition_id(self, partition_id): ) # Verify the partition_id was saved. - split_test = self.get_item_from_modulestore( - self.split_test_usage_key, verify_is_draft=True - ) + split_test = self.get_item_from_modulestore(self.split_test_usage_key) self.assertEqual(partition_id, split_test.user_partition_id) return split_test @@ -2356,7 +2309,7 @@ def _assert_children(self, expected_number): """ Verifies the number of children of the split_test instance. """ - split_test = self.get_item_from_modulestore(self.split_test_usage_key, True) + split_test = self.get_item_from_modulestore(self.split_test_usage_key) self.assertEqual(expected_number, len(split_test.children)) return split_test @@ -2365,9 +2318,7 @@ def test_create_groups(self): Test that verticals are created for the configuration groups when a spit test block is edited. """ - split_test = self.get_item_from_modulestore( - self.split_test_usage_key, verify_is_draft=True - ) + split_test = self.get_item_from_modulestore(self.split_test_usage_key) # Initially, no user_partition_id is set, and the split_test has no children. self.assertEqual(-1, split_test.user_partition_id) self.assertEqual(0, len(split_test.children)) @@ -2377,12 +2328,8 @@ def test_create_groups(self): # Verify that child verticals have been set to match the groups self.assertEqual(2, len(split_test.children)) - vertical_0 = self.get_item_from_modulestore( - split_test.children[0], verify_is_draft=True - ) - vertical_1 = self.get_item_from_modulestore( - split_test.children[1], verify_is_draft=True - ) + vertical_0 = self.get_item_from_modulestore(split_test.children[0]) + vertical_1 = self.get_item_from_modulestore(split_test.children[1]) self.assertEqual("vertical", vertical_0.category) self.assertEqual("vertical", vertical_1.category) self.assertEqual( @@ -2407,9 +2354,7 @@ def test_split_xblock_info_group_name(self): """ Test that concise outline for split test component gives display name as group name. """ - split_test = self.get_item_from_modulestore( - self.split_test_usage_key, verify_is_draft=True - ) + split_test = self.get_item_from_modulestore(self.split_test_usage_key) # Initially, no user_partition_id is set, and the split_test has no children. self.assertEqual(split_test.user_partition_id, -1) self.assertEqual(len(split_test.children), 0) @@ -2451,15 +2396,9 @@ def test_change_user_partition_id(self): self.assertEqual(5, len(split_test.children)) self.assertEqual(initial_vertical_0_location, split_test.children[0]) self.assertEqual(initial_vertical_1_location, split_test.children[1]) - vertical_0 = self.get_item_from_modulestore( - split_test.children[2], verify_is_draft=True - ) - vertical_1 = self.get_item_from_modulestore( - split_test.children[3], verify_is_draft=True - ) - vertical_2 = self.get_item_from_modulestore( - split_test.children[4], verify_is_draft=True - ) + vertical_0 = self.get_item_from_modulestore(split_test.children[2]) + vertical_1 = self.get_item_from_modulestore(split_test.children[3]) + vertical_2 = self.get_item_from_modulestore(split_test.children[4]) # Verify that the group_id_to child mapping is correct. self.assertEqual(3, len(split_test.group_id_to_child)) @@ -2736,10 +2675,7 @@ def setUp(self): XBlockStudioConfiguration.objects.create( name="openassessment", enabled=True, support_level="us" ) - # Library Sourced Block and Library Content block has it's own category. - XBlockStudioConfiguration.objects.create( - name="library_sourced", enabled=True, support_level="fs" - ) + # Library Content block has its own category. XBlockStudioConfiguration.objects.create( name="library_content", enabled=True, support_level="fs" ) @@ -2840,7 +2776,7 @@ def test_basic_components_support_levels(self): self._verify_basic_component("video", "Video", "us") problem_templates = self.get_templates_of_type("problem") problem_no_boilerplate = self.get_template( - problem_templates, "Blank Advanced Problem" + problem_templates, "Blank Problem" ) self.assertIsNotNone(problem_no_boilerplate) self.assertEqual("us", problem_no_boilerplate["support_level"]) @@ -3106,39 +3042,25 @@ def test_json_responses(self): json_response = json.loads(resp.content.decode("utf-8")) self.validate_course_xblock_info(json_response, course_outline=True) - @ddt.data( - (ModuleStoreEnum.Type.split, 3, 3), - (ModuleStoreEnum.Type.mongo, 8, 12), - ) - @ddt.unpack - def test_xblock_outline_handler_mongo_calls( - self, store_type, chapter_queries, chapter_queries_1 - ): - with self.store.default_store(store_type): - course = CourseFactory.create() - chapter = BlockFactory.create( - parent_location=course.location, - category="chapter", - display_name="Week 1", - ) - outline_url = reverse_usage_url("xblock_outline_handler", chapter.location) - with check_mongo_calls(chapter_queries): - self.client.get(outline_url, HTTP_ACCEPT="application/json") - - sequential = BlockFactory.create( - parent_location=chapter.location, - category="sequential", - display_name="Sequential 1", - ) + def test_xblock_outline_handler_mongo_calls(self): + course = CourseFactory.create() + chapter = BlockFactory.create( + parent_location=course.location, category='chapter', display_name='Week 1' + ) + outline_url = reverse_usage_url('xblock_outline_handler', chapter.location) + with check_mongo_calls(3): + self.client.get(outline_url, HTTP_ACCEPT='application/json') - BlockFactory.create( - parent_location=sequential.location, - category="vertical", - display_name="Vertical 1", - ) - # calls should be same after adding two new children for split only. - with check_mongo_calls(chapter_queries_1): - self.client.get(outline_url, HTTP_ACCEPT="application/json") + sequential = BlockFactory.create( + parent_location=chapter.location, category='sequential', display_name='Sequential 1' + ) + + BlockFactory.create( + parent_location=sequential.location, category='vertical', display_name='Vertical 1' + ) + # calls should be same after adding two new children for split only. + with check_mongo_calls(3): + self.client.get(outline_url, HTTP_ACCEPT='application/json') def test_entrance_exam_chapter_xblock_info(self): chapter = BlockFactory.create( @@ -3264,32 +3186,26 @@ def test_component_xblock_info(self): ) self.validate_component_xblock_info(xblock_info) - @ddt.data(ModuleStoreEnum.Type.split, ModuleStoreEnum.Type.mongo) - def test_validate_start_date(self, store_type): + def test_validate_start_date(self): """ Validate if start-date year is less than 1900 reset the date to DEFAULT_START_DATE. """ - with self.store.default_store(store_type): - course = CourseFactory.create() - chapter = BlockFactory.create( - parent_location=course.location, - category="chapter", - display_name="Week 1", - ) + course = CourseFactory.create() + chapter = BlockFactory.create( + parent_location=course.location, category='chapter', display_name='Week 1' + ) - chapter.start = datetime(year=1899, month=1, day=1, tzinfo=UTC) + chapter.start = datetime(year=1899, month=1, day=1, tzinfo=UTC) - xblock_info = create_xblock_info( - chapter, - include_child_info=True, - include_children_predicate=ALWAYS, - include_ancestor_info=True, - user=self.user, - ) + xblock_info = create_xblock_info( + chapter, + include_child_info=True, + include_children_predicate=ALWAYS, + include_ancestor_info=True, + user=self.user + ) - self.assertEqual( - xblock_info["start"], DEFAULT_START_DATE.strftime("%Y-%m-%dT%H:%M:%SZ") - ) + self.assertEqual(xblock_info['start'], DEFAULT_START_DATE.strftime('%Y-%m-%dT%H:%M:%SZ')) def test_highlights_enabled(self): self.course.highlights_enabled_for_messaging = True @@ -3489,9 +3405,11 @@ def setUp(self): user_id=user_id, highlights=["highlight"], ) + # get updated course + self.course = self.store.get_item(self.course.location) self.course.enable_proctored_exams = True self.course.save() - self.store.update_item(self.course, self.user.id) + self.course = self.store.update_item(self.course, self.user.id) def test_proctoring_is_enabled_for_course(self): course = modulestore().get_item(self.course.location) @@ -3517,7 +3435,7 @@ def test_special_exam_xblock_info( category="sequential", display_name="Test Lesson 1", user_id=self.user.id, - is_proctored_exam=True, + is_proctored_enabled=True, is_time_limited=True, default_time_limit_minutes=100, is_onboarding_exam=False, @@ -3561,7 +3479,7 @@ def test_xblock_was_ever_proctortrack_proctored_exam( category="sequential", display_name="Test Lesson 1", user_id=self.user.id, - is_proctored_exam=False, + is_proctored_enabled=False, is_time_limited=False, is_onboarding_exam=False, ) @@ -3589,7 +3507,7 @@ def test_xblock_was_never_proctortrack_proctored_exam( category="sequential", display_name="Test Lesson 1", user_id=self.user.id, - is_proctored_exam=False, + is_proctored_enabled=False, is_time_limited=False, is_onboarding_exam=False, ) @@ -3849,9 +3767,8 @@ def test_empty_chapter(self): xblock_info = self._get_xblock_info(empty_chapter.location) self._verify_visibility_state(xblock_info, VisibilityState.unscheduled) - @ddt.data(ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split) - def test_chapter_self_paced_default_start_date(self, store_type): - course = CourseFactory.create(default_store=store_type) + def test_chapter_self_paced_default_start_date(self): + course = CourseFactory.create() course.self_paced = True self.store.update_item(course, self.user.id) chapter = self._create_child(course, "chapter", "Test Chapter") @@ -3939,29 +3856,15 @@ def test_unpublished_changes(self): ) def test_partially_released_section(self): - chapter = self._create_child(self.course, "chapter", "Test Chapter") - released_sequential = self._create_child( - chapter, "sequential", "Released Sequential" - ) - self._create_child( - released_sequential, "vertical", "Released Unit", publish_item=True - ) - self._create_child( - released_sequential, "vertical", "Staff Only Unit", staff_only=True - ) + chapter = self._create_child(self.course, 'chapter', "Test Chapter") + released_sequential = self._create_child(chapter, 'sequential', "Released Sequential") + self._create_child(released_sequential, 'vertical', "Released Unit", publish_item=True) + self._create_child(released_sequential, 'vertical', "Staff Only Unit 1", staff_only=True) self._set_release_date(chapter.location, datetime.now(UTC) - timedelta(days=1)) - published_sequential = self._create_child( - chapter, "sequential", "Published Sequential" - ) - self._create_child( - published_sequential, "vertical", "Published Unit", publish_item=True - ) - self._create_child( - published_sequential, "vertical", "Staff Only Unit", staff_only=True - ) - self._set_release_date( - published_sequential.location, datetime.now(UTC) + timedelta(days=1) - ) + published_sequential = self._create_child(chapter, 'sequential', "Published Sequential") + self._create_child(published_sequential, 'vertical', "Published Unit", publish_item=True) + self._create_child(published_sequential, 'vertical', "Staff Only Unit 2", staff_only=True) + self._set_release_date(published_sequential.location, datetime.now(UTC) + timedelta(days=1)) xblock_info = self._get_xblock_info(chapter.location) # Verify the state of the released sequential @@ -4191,8 +4094,7 @@ def test_locked_unit_staff_only_message(self): xblock_info, True, path=self.FIRST_UNIT_PATH ) - @ddt.data(ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split) - def test_self_paced_item_visibility_state(self, store_type): + def test_self_paced_item_visibility_state(self): """ Test that in self-paced course, item has `live` visibility state. Test that when item was initially in `scheduled` state in instructor mode, change course pacing to self-paced, @@ -4200,7 +4102,7 @@ def test_self_paced_item_visibility_state(self, store_type): """ # Create course, chapter and setup future release date to make chapter in scheduled state - course = CourseFactory.create(default_store=store_type) + course = CourseFactory.create() chapter = self._create_child(course, "chapter", "Test Chapter") self._set_release_date(chapter.location, datetime.now(UTC) + timedelta(days=1)) diff --git a/cms/djangoapps/contentstore/views/tests/test_container_page.py b/cms/djangoapps/contentstore/views/tests/test_container_page.py index 7bc261651544..b78c0cce8347 100644 --- a/cms/djangoapps/contentstore/views/tests/test_container_page.py +++ b/cms/djangoapps/contentstore/views/tests/test_container_page.py @@ -9,6 +9,7 @@ from django.http import Http404 from django.test.client import RequestFactory +from django.urls import reverse from pytz import UTC from urllib.parse import quote @@ -31,36 +32,42 @@ class ContainerPageTestCase(StudioPageTestCase, LibraryTestCase): def setUp(self): super().setUp() - self.vertical = self._create_block(self.sequential.location, 'vertical', 'Unit') - self.html = self._create_block(self.vertical.location, "html", "HTML") - self.child_container = self._create_block(self.vertical.location, 'split_test', 'Split Test') - self.child_vertical = self._create_block(self.child_container.location, 'vertical', 'Child Vertical') - self.video = self._create_block(self.child_vertical.location, "video", "My Video") + self.vertical = self._create_block(self.sequential, 'vertical', 'Unit') + self.html = self._create_block(self.vertical, "html", "HTML") + self.child_container = self._create_block(self.vertical, 'split_test', 'Split Test') + self.child_vertical = self._create_block(self.child_container, 'vertical', 'Child Vertical') + self.video = self._create_block(self.child_vertical, "video", "My Video") self.store = modulestore() past = datetime.datetime(1970, 1, 1, tzinfo=UTC) future = datetime.datetime.now(UTC) + datetime.timedelta(days=1) self.released_private_vertical = self._create_block( - parent_location=self.sequential.location, category='vertical', display_name='Released Private Unit', + parent=self.sequential, category='vertical', display_name='Released Private Unit', start=past) self.unreleased_private_vertical = self._create_block( - parent_location=self.sequential.location, category='vertical', display_name='Unreleased Private Unit', + parent=self.sequential, category='vertical', display_name='Unreleased Private Unit', start=future) self.released_public_vertical = self._create_block( - parent_location=self.sequential.location, category='vertical', display_name='Released Public Unit', + parent=self.sequential, category='vertical', display_name='Released Public Unit', start=past) self.unreleased_public_vertical = self._create_block( - parent_location=self.sequential.location, category='vertical', display_name='Unreleased Public Unit', + parent=self.sequential, category='vertical', display_name='Unreleased Public Unit', start=future) self.store.publish(self.unreleased_public_vertical.location, self.user.id) self.store.publish(self.released_public_vertical.location, self.user.id) + self.store.publish(self.vertical.location, self.user.id) def test_container_html(self): + assets_url = reverse( + 'assets_handler', kwargs={'course_key_string': str(self.child_container.location.course_key)} + ) self._test_html_content( self.child_container, expected_section_tag=( '